summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGES114
-rw-r--r--MANIFEST.in2
-rw-r--r--admin/benchmarks.py36
-rw-r--r--choose_rounds.py177
-rw-r--r--docs/_static/bb-logo.pngbin0 -> 17166 bytes
-rw-r--r--docs/_static/bb-logo.svg929
-rw-r--r--docs/_static/masthead.pngbin7827 -> 9173 bytes
-rw-r--r--docs/_static/masthead.svg42
-rw-r--r--docs/conf.py34
-rw-r--r--docs/contents.rst2
-rw-r--r--docs/dev-requirements.txt1
-rw-r--r--docs/index.rst18
-rw-r--r--docs/install.rst25
-rw-r--r--docs/lib/passlib.context.rst7
-rw-r--r--docs/lib/passlib.exc.rst2
-rw-r--r--docs/lib/passlib.ext.django.rst7
-rw-r--r--docs/lib/passlib.hash.cisco_asa.rst14
-rw-r--r--docs/lib/passlib.hash.cisco_pix.rst43
-rw-r--r--docs/lib/passlib.hash.rst1
-rw-r--r--docs/lib/passlib.pwd.rst48
-rw-r--r--docs/lib/passlib.totp.rst76
-rw-r--r--docs/lib/passlib.utils.pbkdf2.rst3
-rw-r--r--docs/requirements.txt1
-rw-r--r--passlib/__init__.py2
-rw-r--r--passlib/_data/beale.wordset.zbin0 -> 18325 bytes
-rw-r--r--passlib/_data/diceware.wordset.zbin0 -> 18519 bytes
-rw-r--r--passlib/_data/electrum.wordset.zbin0 -> 5325 bytes
-rw-r--r--passlib/_setup/stamp.py1
-rw-r--r--passlib/apache.py45
-rw-r--r--passlib/context.py484
-rw-r--r--passlib/exc.py15
-rw-r--r--passlib/ext/django/models.py193
-rw-r--r--passlib/ext/django/utils.py37
-rw-r--r--passlib/handlers/bcrypt.py252
-rw-r--r--passlib/handlers/cisco.py57
-rw-r--r--passlib/handlers/des_crypt.py122
-rw-r--r--passlib/handlers/digests.py3
-rw-r--r--passlib/handlers/django.py13
-rw-r--r--passlib/handlers/fshp.py5
-rw-r--r--passlib/handlers/ldap_digests.py10
-rw-r--r--passlib/handlers/md5_crypt.py40
-rw-r--r--passlib/handlers/misc.py10
-rw-r--r--passlib/handlers/mssql.py8
-rw-r--r--passlib/handlers/mysql.py4
-rw-r--r--passlib/handlers/oracle.py9
-rw-r--r--passlib/handlers/pbkdf2.py8
-rw-r--r--passlib/handlers/phpass.py4
-rw-r--r--passlib/handlers/postgres.py4
-rw-r--r--passlib/handlers/scram.py56
-rw-r--r--passlib/handlers/sha1_crypt.py61
-rw-r--r--passlib/handlers/sha2_crypt.py71
-rw-r--r--passlib/handlers/sun_md5_crypt.py74
-rw-r--r--passlib/handlers/windows.py5
-rw-r--r--passlib/hash.py6
-rw-r--r--passlib/hosts.py1
-rw-r--r--passlib/ifc.py99
-rw-r--r--passlib/pwd.py653
-rw-r--r--passlib/registry.py3
-rw-r--r--passlib/tests/backports.py298
-rw-r--r--passlib/tests/test_apache.py124
-rw-r--r--passlib/tests/test_context.py122
-rw-r--r--passlib/tests/test_context_deprecated.py22
-rw-r--r--passlib/tests/test_ext_django.py435
-rw-r--r--passlib/tests/test_handlers.py219
-rw-r--r--passlib/tests/test_handlers_bcrypt.py90
-rw-r--r--passlib/tests/test_handlers_django.py35
-rw-r--r--passlib/tests/test_hosts.py1
-rw-r--r--passlib/tests/test_pwd.py101
-rw-r--r--passlib/tests/test_registry.py9
-rw-r--r--passlib/tests/test_totp.py2115
-rw-r--r--passlib/tests/test_utils.py148
-rw-r--r--passlib/tests/test_utils_crypto.py213
-rw-r--r--passlib/tests/test_utils_handlers.py128
-rw-r--r--passlib/tests/test_win32.py1
-rw-r--r--passlib/tests/utils.py820
-rw-r--r--passlib/totp.py2182
-rw-r--r--passlib/utils/__init__.py56
-rw-r--r--passlib/utils/_blowfish/__init__.py6
-rw-r--r--passlib/utils/_blowfish/base.py1
-rw-r--r--passlib/utils/compat/__init__.py (renamed from passlib/utils/compat.py)110
-rw-r--r--passlib/utils/compat/_ordered_dict.py242
-rw-r--r--passlib/utils/des.py6
-rw-r--r--passlib/utils/handlers.py620
-rw-r--r--passlib/utils/md4.py12
-rw-r--r--passlib/utils/pbkdf2.py374
-rw-r--r--passlib/win32.py6
-rw-r--r--setup.cfg2
-rw-r--r--setup.py11
-rw-r--r--tox.ini41
89 files changed, 9816 insertions, 2671 deletions
diff --git a/CHANGES b/CHANGES
index 381b28c..921961b 100644
--- a/CHANGES
+++ b/CHANGES
@@ -6,22 +6,112 @@
Release History
===============
-Upcoming in **1.7**
-===================
-.. warning::
+**1.7** (NOT YET RELEASED)
+==========================
- The upcoming Passlib 1.7 will make a number of backwards incompatible changes:
+.. todo::
- * **It will require Python 2.6 / 3.2 or newer;
- dropping support for Python 2.5, 3.0, 3.1**
+ The following tasks are blocking the 1.7 release:
- * The :mod:`passlib.ext.django` extension will require Django 1.6 or newer;
- dropping support for Django 1.5 and earlier.
+ * Finish the :mod:`passlib.totp` module (mainly tests & documentation)
- * New hashes generated by :class:`!HtpasswdFile` will use the strongest
- algorithm available on the host, rather than one that is guaranteed to be portable.
- Applications can explicitly set ``default_scheme="portable"`` to retain the old behavior
- (new in Passlib 1.6.3).
+ * Get scrypt hash implemented, or push it to later release (really want to include it)
+
+ * Finish :mod:`passlib.pwd` -- need to finalize API and document it better.
+
+ * Would strongly like to also add pbkdf2 & bcrypt variants which have support for an external
+ "pepper" to further increase security.
+
+ * Internal cleanups:
+
+ * Check for additions to Django's hashers as of Django 1.7
+
+ * Potentially reorg the utils module (particularly the crypto bits), it's getting messy.
+
+ * Finish the :meth:`PasswordHash.using` and :meth:`PasswordHash.needs_update` refactoring,
+ aimed at eliminating any need for the internal :class:`passlib.context._CryptRecord` class.
+
+ * FIXME: Default using() method won't work correctly for wrapper handlers just yet.
+
+Requirements
+------------
+
+ * **Passlib now requires Python 2.6, 2.7, or >= 3.2**.
+ Support for Python versions 2.5, 3.0, and 3.1 has been dropped
+ (and Python 3.2 may be dropped in the next major release).
+ Support for PyPy 1.x has also been dropped.
+
+ * The :mod:`passlib.ext.django` extension now requires Django 1.6 or better.
+ Django 0.9 .. 1.5 are no longer supported.
+
+Major Changes
+-------------
+* New :mod:`passlib.totp` module provides full support for TOTP & HOTP tokens
+ on both client and server side. Contains both low-level primitives, and high-level
+ helpers for persisting and tracking client state.
+
+* New :mod:`passlib.pwd` module added to aid in password generation
+ and strength measurement (with contributions from Thomas Waldmann).
+
+* Added :class:`~passlib.hash.cisco_asa` which provides (tentative)
+ support for Cisco ASA 7.0 and newer hashes.
+
+* The :func:`~passlib.utils.pbkdf2.pbkdf2` function and all PBKDF2-based
+ hashes have been sped up by ~20% compared to Passlib 1.6.
+
+* Passlib will now detect and work around the fatal concurrency bug
+ in py-bcrypt 0.2 and earlier (a :exc:`~passlib.exc.PasslibSecurityWarning`
+ will also be issued). Nevertheless, users are *strongly* encouraged
+ to upgrade to py-bcrypt 0.3 or another bcrypt library if you are using
+ the :doc:`bcrypt </lib/passlib.hash.bcrypt>` hash.
+
+Minor Changes
+-------------
+* The shared :class:`!PasswordHash` unittests now check all hash handlers for
+ basic thread-safety (motivated by the pybcrypt 0.2 concurrency bug).
+
+* The internal support module :mod:`passlib.utils.pbkdf2` has gained
+ to new helpers: :func:`~passlib.utils.pbkdf2.get_hash_info`
+ and :func:`~passlib.utils.pbkdf2.get_keyed_prf`.
+
+Deprecations
+------------
+* The :func:`~passlib.utils.generate_secret` function has been deprecated
+ in favor of the new :mod:`passlib.pwd` module, and will be removed
+ in Passlib 2.0.
+
+* :class:`passlib.utils.handlers.HasManyBackends` internal API change
+
+ Applications implementing a custom handler with multiple backends
+ should now provide :samp:`_load_backend_{name}` classmethod for each backend.
+ This is simpler, more flexible, and more explicit than the
+ previous (1.6 and earlier) API, which required a :samp:`_has_backend_{name}`
+ class property and a :samp:`_calc_checksum_{name}` method. Support
+ for the older API is deprecated, and will it be removed in Passlib 2.0.
+
+Internal Changes
+----------------
+* The majority of CryptContext's internal rounds handling & migration code has been
+ moved to the password hashes themselves, via the new :meth:`~passlib.ifc.PasswordHash.using`
+ and :meth:`~passlib.ifc.PasswordHash.needs_update` methods. This allows much more flexibility
+ when using a hash directly, rather than via CryptContext, as well making it easier for
+ CryptContext to support hash-specific parameters.
+
+Backwards Incompatibilities
+---------------------------
+* The :ref:`min_verify_time <context-min-verify-time-option>` keyword
+ that was deprecated in release 1.6, is now completely ignored.
+ It was never very useful, and now complicates the internal code needlessly.
+ It will be removed entirely in release 1.8.
+
+* New hashes generated by :class:`!HtpasswdFile` now use the strongest
+ algorithm available on the host, rather than one that is guaranteed to be portable.
+ Applications can explicitly set ``default_scheme="portable"`` to retain the old behavior.
+
+Todo
+----
+
+* Thread safety audit and tests for CryptContext, HasManyBackends, and lazy-init subclasses.
**1.6.5** (2015-08-04)
======================
diff --git a/MANIFEST.in b/MANIFEST.in
index 447028c..0b0c9f4 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,2 @@
recursive-include docs *
-include LICENSE README CHANGES passlib/tests/*.cfg tox.ini setup.cfg
+include LICENSE README CHANGES passlib/_data/* passlib/tests/*.cfg tox.ini setup.cfg
diff --git a/admin/benchmarks.py b/admin/benchmarks.py
index 1491336..1a01951 100644
--- a/admin/benchmarks.py
+++ b/admin/benchmarks.py
@@ -17,8 +17,6 @@ sys.path.insert(0, os.curdir)
# core
from binascii import hexlify
import logging; log = logging.getLogger(__name__)
-import os
-import warnings
# site
# pkg
try:
@@ -26,7 +24,7 @@ try:
except ImportError:
PasslibConfigWarning = None
import passlib.utils.handlers as uh
-from passlib.utils.compat import u, print_, unicode, next_method_attr
+from passlib.utils.compat import u, print_, unicode
# local
#=============================================================================
@@ -270,6 +268,16 @@ def test_phpass():
handler.verify(OTHER, hash)
return helper
+@benchmark.constructor()
+def test_sha1_crypt():
+ from passlib.hash import sha1_crypt as handler
+ kwds = dict(salt='.'*8, rounds=10000)
+ def helper():
+ hash = handler.encrypt(SECRET, **kwds)
+ handler.verify(SECRET, hash)
+ handler.verify(OTHER, hash)
+ return helper
+
#=============================================================================
# crypto utils
#=============================================================================
@@ -277,16 +285,30 @@ def test_phpass():
def test_pbkdf2_sha1():
from passlib.utils.pbkdf2 import pbkdf2
def helper():
- result = hexlify(pbkdf2("abracadabra", "open sesasme", 40960, 20, "hmac-sha1"))
- assert result == 'ad317ed77bce584c90932b609e37e3736e6297bf', result
+ result = hexlify(pbkdf2("abracadabra", "open sesame", 10240, 20, "hmac-sha1"))
+ assert result == 'e45ce658e79b16107a418ad4634836f5f0601ad1', result
return helper
@benchmark.constructor()
def test_pbkdf2_sha256():
from passlib.utils.pbkdf2 import pbkdf2
def helper():
- result = hexlify(pbkdf2("abracadabra", "open sesasme", 10240, 32, "hmac-sha256"))
- assert result == '21d1ac0d474aaec49feb4f2172a266223e43edcf1052643dd27d82ebd5fa10c6', result
+ result = hexlify(pbkdf2("abracadabra", "open sesame", 10240, 32, "hmac-sha256"))
+ assert result == 'fadef97054306c93c55213cd57111d6c0791735dcdde8ac32f9f934b49c5af1e', result
+ return helper
+
+#=============================================================================
+# entropy estimates
+#=============================================================================
+@benchmark.constructor()
+def test_average_entropy():
+ from passlib.pwd import _average_entropy
+ testc = "abcdef"*100000
+ def helper():
+ _average_entropy(testc)
+ _average_entropy(testc, True)
+ _average_entropy(iter(testc))
+ _average_entropy(iter(testc), True)
return helper
#=============================================================================
diff --git a/choose_rounds.py b/choose_rounds.py
new file mode 100644
index 0000000..0fc26b8
--- /dev/null
+++ b/choose_rounds.py
@@ -0,0 +1,177 @@
+"""cli helper for selecting appropriate <rounds> value for a given hash"""
+#=============================================================================
+# imports
+#=============================================================================
+from __future__ import division
+# core
+import math
+import logging; log = logging.getLogger(__name__)
+import sys
+# site
+# pkg
+from passlib.registry import get_crypt_handler
+from passlib.utils import tick
+# local
+__all__ = [
+ "main",
+]
+
+#=============================================================================
+# main
+#=============================================================================
+_usage = "usage: python choose_rounds.py <hash_name> [<target_milliseconds>] [<backend>]\n"
+
+def main(*args):
+ #---------------------------------------------------------------
+ # parse args
+ #---------------------------------------------------------------
+ args = list(args)
+ def print_error(msg):
+ print "error: %s\n" % msg
+
+ # parse hasher
+ if args:
+ name = args.pop(0)
+ if name == "-h" or name == "--help":
+ print _usage
+ return 1
+ try:
+ hasher = get_crypt_handler(name)
+ except KeyError:
+ print_error("unknown hash %r" % name)
+ return 1
+ if 'rounds' not in hasher.setting_kwds:
+ print_error("%s does not support variable rounds" % name)
+ return 1
+ else:
+ print_error("hash name not specified")
+ print _usage
+ return 1
+
+ # parse target time
+ if args:
+ try:
+ target = int(args.pop(0))*.001
+ if target <= 0:
+ raise ValueError
+ except ValueError:
+ print_error("target time must be integer milliseconds > 0")
+ return 1
+ else:
+ target = .350
+
+ # parse backend
+ if args:
+ backend = args.pop(0)
+ if hasattr(hasher, "set_backend"):
+ hasher.set_backend(backend)
+ else:
+ print_error("%s does not support multiple backends")
+ return 1
+
+ #---------------------------------------------------------------
+ # setup some helper functions
+ #---------------------------------------------------------------
+ if hasher.rounds_cost == "log2":
+ # time cost varies logarithmically with rounds parameter,
+ # so speed = (2**rounds) / elapsed
+ def rounds_to_cost(rounds):
+ return 2 ** rounds
+ def cost_to_rounds(cost):
+ return math.log(cost, 2)
+ else:
+ # time cost varies linearly with rounds parameter,
+ # so speed = rounds / elapsed
+ assert hasher.rounds_cost == "linear"
+ rounds_to_cost = cost_to_rounds = lambda value: value
+
+ def clamp_rounds(rounds):
+ """convert float rounds to int value, clamped to hasher's limits"""
+ if hasher.max_rounds and rounds > hasher.max_rounds:
+ rounds = hasher.max_rounds
+ rounds = int(rounds)
+ if getattr(hasher, "_avoid_even_rounds", False):
+ rounds |= 1
+ return max(hasher.min_rounds, rounds)
+
+ def average(seq):
+ if not hasattr(seq, "__length__"):
+ seq = tuple(seq)
+ return sum(seq) / len(seq)
+
+ def estimate_speed(rounds):
+ """estimate speed using specified # of rounds"""
+ # time a single verify() call
+ secret = "S0m3-S3Kr1T"
+ hash = hasher.encrypt(secret, rounds=rounds)
+ def helper():
+ start = tick()
+ hasher.verify(secret, hash)
+ return tick() - start
+ # try to get average time over a few samples
+ # XXX: way too much variability between sampling runs,
+ # would like to improve this bit
+ elapsed = min(average(helper() for _ in range(4)) for _ in range(4))
+ return rounds_to_cost(rounds) / elapsed
+
+ #---------------------------------------------------------------
+ # get rough estimate of speed using fraction of default_rounds
+ # (so we don't take crazy long amounts of time on slow systems)
+ #---------------------------------------------------------------
+ rounds = clamp_rounds(cost_to_rounds(.5 * rounds_to_cost(hasher.default_rounds)))
+ speed = estimate_speed(rounds)
+
+ #---------------------------------------------------------------
+ # re-do estimate using previous result,
+ # to get more accurate sample using a larger number of rounds.
+ #---------------------------------------------------------------
+ for _ in range(2):
+ rounds = clamp_rounds(cost_to_rounds(speed * target))
+ speed = estimate_speed(rounds)
+
+ #---------------------------------------------------------------
+ # using final estimate, calc desired number of rounds for target time
+ #---------------------------------------------------------------
+ if hasattr(hasher, "backends"):
+ name = "%s (using %s backend)" % (name, hasher.get_backend())
+ print "hash............: %s" % name
+ if speed < 1000:
+ speedstr = "%.2f" % speed
+ else:
+ speedstr = int(speed)
+ print "speed...........: %s iterations/second" % speedstr
+ print "target time.....: %d ms" % (target*1000,)
+ rounds = cost_to_rounds(speed * target)
+ if hasher.rounds_cost == "log2":
+ # for log2 rounds parameter, target time will usually fall
+ # somewhere between two integer values, which will have large gulf
+ # between them. if target is within <tolerance> percent of
+ # one of two ends, report it, otherwise list both and let user decide.
+ tolerance = .05
+ lower = clamp_rounds(rounds)
+ upper = clamp_rounds(math.ceil(rounds))
+ lower_elapsed = rounds_to_cost(lower) / speed
+ upper_elapsed = rounds_to_cost(upper) / speed
+ if (target-lower_elapsed)/target < tolerance:
+ print "target rounds...: %d" % lower
+ elif (upper_elapsed-target)/target < tolerance:
+ print "target rounds...: %d" % upper
+ else:
+ faster = (target - lower_elapsed)
+ print "target rounds...: %d (%dms -- %dms/%d%% faster than requested)" % \
+ (lower, lower_elapsed*1000, faster * 1000, round(100 * faster / target))
+ slower = (upper_elapsed - target)
+ print "target rounds...: %d (%dms -- %dms/%d%% slower than requested)" % \
+ (upper, upper_elapsed*1000, slower * 1000, round(100 * slower / target))
+ else:
+ # for linear rounds parameter, just use nearest integer value
+ rounds = clamp_rounds(round(rounds))
+ print "target rounds...: %d" % (rounds,)
+ print
+
+if __name__ == "__main__":
+ sys.exit(main(*sys.argv[1:]))
+
+#=============================================================================
+# eof
+#=============================================================================
diff --git a/docs/_static/bb-logo.png b/docs/_static/bb-logo.png
new file mode 100644
index 0000000..d4867ac
--- /dev/null
+++ b/docs/_static/bb-logo.png
Binary files differ
diff --git a/docs/_static/bb-logo.svg b/docs/_static/bb-logo.svg
new file mode 100644
index 0000000..b5d534d
--- /dev/null
+++ b/docs/_static/bb-logo.svg
@@ -0,0 +1,929 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="48"
+ height="48"
+ id="svg2383"
+ sodipodi:version="0.32"
+ inkscape:version="0.48.3.1 r9886"
+ sodipodi:docname="bb-logo.svg"
+ inkscape:output_extension="org.inkscape.output.svg.inkscape"
+ inkscape:export-filename="/home/biscuit/dev/libs/passlib/trunk/docs/_static/bb-logo.png"
+ inkscape:export-xdpi="240"
+ inkscape:export-ydpi="240"
+ version="1.0"
+ style="display:inline">
+ <defs
+ id="defs2385">
+ <linearGradient
+ id="linearGradient3918">
+ <stop
+ id="stop3920"
+ offset="0"
+ style="stop-color:#000000;stop-opacity:1;" />
+ <stop
+ id="stop3922"
+ offset="1"
+ style="stop-color:#000000;stop-opacity:0;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient4661">
+ <stop
+ style="stop-color:#e5f2ff;stop-opacity:0;"
+ offset="0"
+ id="stop4663" />
+ <stop
+ style="stop-color:#e5f2ff;stop-opacity:1;"
+ offset="1"
+ id="stop4665" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3426">
+ <stop
+ style="stop-color:#cdcdcd;stop-opacity:1;"
+ offset="0"
+ id="stop3428" />
+ <stop
+ style="stop-color:#989898;stop-opacity:1;"
+ offset="1"
+ id="stop3430" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3361">
+ <stop
+ style="stop-color:#d1d1d1;stop-opacity:1;"
+ offset="0"
+ id="stop3363" />
+ <stop
+ style="stop-color:#85867f;stop-opacity:0;"
+ offset="1"
+ id="stop3365" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3343">
+ <stop
+ style="stop-color:#e7e8e7;stop-opacity:1;"
+ offset="0"
+ id="stop3345" />
+ <stop
+ id="stop3351"
+ offset="0.35526317"
+ style="stop-color:#85867f;stop-opacity:1;" />
+ <stop
+ style="stop-color:#8a8b85;stop-opacity:1;"
+ offset="0.55263162"
+ id="stop3357" />
+ <stop
+ style="stop-color:#e8e8e6;stop-opacity:1;"
+ offset="0.75"
+ id="stop3353" />
+ <stop
+ id="stop3355"
+ offset="0.875"
+ style="stop-color:#e3e3e2;stop-opacity:1;" />
+ <stop
+ style="stop-color:#85867f;stop-opacity:1;"
+ offset="1"
+ id="stop3347" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3330">
+ <stop
+ style="stop-color:#63645e;stop-opacity:1;"
+ offset="0"
+ id="stop3332" />
+ <stop
+ style="stop-color:#d8d9d7;stop-opacity:1;"
+ offset="1"
+ id="stop3334" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3174">
+ <stop
+ style="stop-color:#ffffff;stop-opacity:0;"
+ offset="0"
+ id="stop3176" />
+ <stop
+ id="stop3182"
+ offset="0.02577317"
+ style="stop-color:#ffffff;stop-opacity:0;" />
+ <stop
+ style="stop-color:#ffffff;stop-opacity:0.68103451;"
+ offset="0.10257728"
+ id="stop3184" />
+ <stop
+ id="stop3186"
+ offset="0.29355666"
+ style="stop-color:#ffffff;stop-opacity:0;" />
+ <stop
+ style="stop-color:#ffffff;stop-opacity:0;"
+ offset="0.49417913"
+ id="stop3188" />
+ <stop
+ id="stop3190"
+ offset="0.76791382"
+ style="stop-color:#ffffff;stop-opacity:0.68103451;" />
+ <stop
+ style="stop-color:#ffffff;stop-opacity:0.70689654;"
+ offset="0.83300149"
+ id="stop3194" />
+ <stop
+ style="stop-color:#ffffff;stop-opacity:0;"
+ offset="1"
+ id="stop3178" />
+ </linearGradient>
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 24 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="48 : 24 : 1"
+ inkscape:persp3d-origin="24 : 16 : 1"
+ id="perspective2391" />
+ <inkscape:perspective
+ id="perspective2511"
+ inkscape:persp3d-origin="372.04724 : 350.78739 : 1"
+ inkscape:vp_z="744.09448 : 526.18109 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_x="0 : 526.18109 : 1"
+ sodipodi:type="inkscape:persp3d" />
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3174"
+ id="linearGradient3180"
+ x1="6.2500014"
+ y1="26.857143"
+ x2="39.892857"
+ y2="26.857143"
+ gradientUnits="userSpaceOnUse" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3330"
+ id="radialGradient3338"
+ cx="24.08217"
+ cy="6.5837455"
+ fx="24.08217"
+ fy="6.5837455"
+ r="3.3319807"
+ gradientTransform="matrix(1,0,0,0.3178702,0,4.490969)"
+ gradientUnits="userSpaceOnUse" />
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3343"
+ id="linearGradient3349"
+ x1="20.156134"
+ y1="9.6145229"
+ x2="27.476151"
+ y2="9.6145229"
+ gradientUnits="userSpaceOnUse" />
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3426"
+ id="linearGradient3432"
+ x1="20.619965"
+ y1="4.9160261"
+ x2="23.637569"
+ y2="12.999183"
+ gradientUnits="userSpaceOnUse" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3361"
+ id="radialGradient3238"
+ cx="23.637569"
+ cy="8.9576044"
+ fx="23.637569"
+ fy="8.9576044"
+ r="14.501575"
+ gradientTransform="matrix(1.0932998,4.0390633e-7,-7.5505546e-8,0.3972952,-2.2053809,5.3987817)"
+ gradientUnits="userSpaceOnUse" />
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient4140"
+ id="linearGradient4152"
+ x1="12.784937"
+ y1="17.261765"
+ x2="42.609146"
+ y2="21.100046"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(0.9898657,0.5290083,-0.52817535,0.98830712,8.6007275,-14.225263)" />
+ <linearGradient
+ id="linearGradient4140">
+ <stop
+ style="stop-color:#ffe50a;stop-opacity:1;"
+ offset="0"
+ id="stop4142" />
+ <stop
+ id="stop4150"
+ offset="0.49036095"
+ style="stop-color:#fcea5b;stop-opacity:1;" />
+ <stop
+ id="stop4148"
+ offset="0.60424012"
+ style="stop-color:#edd400;stop-opacity:1;" />
+ <stop
+ style="stop-color:#d6c000;stop-opacity:1;"
+ offset="1"
+ id="stop4144" />
+ </linearGradient>
+ <linearGradient
+ y2="21.100046"
+ x2="42.609146"
+ y1="17.261765"
+ x1="12.784937"
+ gradientTransform="matrix(0.7849926,0.71234709,-0.77051651,0.74167681,21.973474,-14.200118)"
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient4350-3"
+ xlink:href="#linearGradient4140-0"
+ inkscape:collect="always" />
+ <linearGradient
+ id="linearGradient4140-0">
+ <stop
+ style="stop-color:#ffe50a;stop-opacity:1;"
+ offset="0"
+ id="stop4142-3" />
+ <stop
+ id="stop4150-5"
+ offset="0.49036095"
+ style="stop-color:#fcea5b;stop-opacity:1;" />
+ <stop
+ id="stop4148-1"
+ offset="0.60424012"
+ style="stop-color:#edd400;stop-opacity:1;" />
+ <stop
+ style="stop-color:#d6c000;stop-opacity:1;"
+ offset="1"
+ id="stop4144-4" />
+ </linearGradient>
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4270"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <linearGradient
+ id="linearGradient3910">
+ <stop
+ style="stop-color:#ffffff;stop-opacity:1;"
+ offset="0"
+ id="stop3912" />
+ <stop
+ style="stop-color:#ffffff;stop-opacity:0;"
+ offset="1"
+ id="stop3914" />
+ </linearGradient>
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4184"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4192"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4200"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4208"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4216"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4224"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4232"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4240"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4248"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4256"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4264"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4272"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4280"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4288"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4296"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4304"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient4312"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
+ cx="67.387276"
+ cy="44.127342"
+ fx="67.387276"
+ fy="44.127342"
+ r="21.54225" />
+ <radialGradient
+ r="21.54225"
+ fy="44.127342"
+ fx="67.387276"
+ cy="44.127342"
+ cx="67.387276"
+ gradientTransform="matrix(-1.1712043,0.01453176,-0.01461293,-1.1777453,104.6584,74.989578)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient4338"
+ xlink:href="#linearGradient3918"
+ inkscape:collect="always" />
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient4140-8"
+ id="linearGradient4152-1"
+ x1="12.904935"
+ y1="15.496107"
+ x2="37.260765"
+ y2="15.455728"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(0.89475138,0.48634451,-0.47742398,0.90860151,-68.10774,-12.541272)" />
+ <linearGradient
+ id="linearGradient4140-8">
+ <stop
+ style="stop-color:#ffe50a;stop-opacity:1;"
+ offset="0"
+ id="stop4142-0" />
+ <stop
+ id="stop4150-3"
+ offset="0.49036095"
+ style="stop-color:#fbde04;stop-opacity:1;" />
+ <stop
+ id="stop4148-5"
+ offset="0.60424012"
+ style="stop-color:#ffe721;stop-opacity:1;" />
+ <stop
+ style="stop-color:#ffe504;stop-opacity:1;"
+ offset="1"
+ id="stop4144-3" />
+ </linearGradient>
+ <filter
+ color-interpolation-filters="sRGB"
+ inkscape:collect="always"
+ id="filter3534"
+ x="-0.11470588"
+ width="1.2294118"
+ y="-0.41785714"
+ height="1.8357143">
+ <feGaussianBlur
+ inkscape:collect="always"
+ stdDeviation="2.9546961"
+ id="feGaussianBlur3536" />
+ </filter>
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient4140-8-8"
+ id="linearGradient4152-1-4"
+ x1="12.784937"
+ y1="17.261765"
+ x2="42.609146"
+ y2="21.100046"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(0.95106941,0.50846877,-0.50747431,0.94993463,11.778458,-14.313674)" />
+ <linearGradient
+ id="linearGradient4140-8-8">
+ <stop
+ style="stop-color:#ffe50a;stop-opacity:1;"
+ offset="0"
+ id="stop4142-0-0" />
+ <stop
+ id="stop4150-3-9"
+ offset="0.49036095"
+ style="stop-color:#fcea5b;stop-opacity:1;" />
+ <stop
+ id="stop4148-5-4"
+ offset="0.60424012"
+ style="stop-color:#edd400;stop-opacity:1;" />
+ <stop
+ style="stop-color:#d6c000;stop-opacity:1;"
+ offset="1"
+ id="stop4144-3-3" />
+ </linearGradient>
+ <filter
+ inkscape:collect="always"
+ id="filter4616"
+ x="-0.14693342"
+ width="1.2938668"
+ y="-0.101411"
+ height="1.202822">
+ <feGaussianBlur
+ inkscape:collect="always"
+ stdDeviation="1.7722476"
+ id="feGaussianBlur4618" />
+ </filter>
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient4140-8-4"
+ id="linearGradient4152-1-9"
+ x1="12.904935"
+ y1="15.496107"
+ x2="37.260765"
+ y2="15.455728"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(0.89475138,0.48634451,-0.47742398,0.90860151,11.89226,-12.541272)" />
+ <linearGradient
+ id="linearGradient4140-8-4">
+ <stop
+ style="stop-color:#ffe50a;stop-opacity:1;"
+ offset="0"
+ id="stop4142-0-7" />
+ <stop
+ id="stop4150-3-5"
+ offset="0.49036095"
+ style="stop-color:#fdef86;stop-opacity:1;" />
+ <stop
+ id="stop4148-5-5"
+ offset="0.60424012"
+ style="stop-color:#e2ca00;stop-opacity:1;" />
+ <stop
+ style="stop-color:#d6c000;stop-opacity:1;"
+ offset="1"
+ id="stop4144-3-5" />
+ </linearGradient>
+ <linearGradient
+ y2="15.455728"
+ x2="37.260765"
+ y1="15.496107"
+ x1="12.904935"
+ gradientTransform="matrix(0.89475138,0.48634451,-0.47742398,0.90860151,11.453248,-12.385601)"
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient4710"
+ xlink:href="#linearGradient4140-8-4"
+ inkscape:collect="always" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3910"
+ id="radialGradient3914"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(-1.4100094,-0.001228,0.00120855,-1.3876939,120.16323,84.920572)"
+ cx="67.892921"
+ cy="42.971691"
+ fx="67.892921"
+ fy="42.971691"
+ r="21.54225" />
+ </defs>
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#f5f5f5"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="1"
+ inkscape:pageshadow="2"
+ inkscape:zoom="2.3422242"
+ inkscape:cx="4.9273091"
+ inkscape:cy="29.032933"
+ inkscape:current-layer="layer2"
+ showgrid="true"
+ inkscape:grid-bbox="true"
+ inkscape:document-units="px"
+ inkscape:window-width="1920"
+ inkscape:window-height="1025"
+ inkscape:window-x="0"
+ inkscape:window-y="0"
+ borderlayer="true"
+ inkscape:window-maximized="1"
+ showborder="false" />
+ <metadata
+ id="metadata2388">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="dark bg"
+ sodipodi:insensitive="true"
+ style="display:inline">
+ <rect
+ style="fill:#5b9fd4;fill-opacity:1;fill-rule:nonzero;stroke:none"
+ id="rect3106"
+ width="48"
+ height="48"
+ x="0"
+ y="-3.5527137e-15" />
+ </g>
+ <g
+ inkscape:label="light bg"
+ id="g3924"
+ inkscape:groupmode="layer"
+ style="display:none">
+ <rect
+ y="-3.5527137e-15"
+ x="0"
+ height="48"
+ width="48"
+ id="rect3926"
+ style="fill:#e5f2ff;fill-opacity:1;fill-rule:nonzero;stroke:none" />
+ </g>
+ <g
+ style="display:none"
+ inkscape:label="light numbers"
+ id="g3876"
+ inkscape:groupmode="layer"
+ sodipodi:insensitive="true">
+ <text
+ sodipodi:linespacing="125%"
+ id="text3878"
+ y="-44.291798"
+ x="-14.810593"
+ style="font-size:6.01588392px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;opacity:0.34213125;fill:url(#radialGradient3914);fill-opacity:1;stroke:none;display:inline;font-family:Sans"
+ xml:space="preserve"><tspan
+ id="tspan3880"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="-44.291798"
+ x="-14.810593"
+ sodipodi:role="line">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ id="tspan3882"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="-36.771942"
+ x="-14.810593"
+ sodipodi:role="line">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ id="tspan3884"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="-29.252089"
+ x="-14.810593"
+ sodipodi:role="line">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ id="tspan3886"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="-21.732233"
+ x="-14.810593"
+ sodipodi:role="line">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ id="tspan3888"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="-14.212379"
+ x="-14.810593"
+ sodipodi:role="line">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ id="tspan3890"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="-6.692523"
+ x="-14.810593"
+ sodipodi:role="line">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ id="tspan3892"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="0.82733178"
+ x="-14.810593"
+ sodipodi:role="line">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ id="tspan3894"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="8.347187"
+ x="-14.810593"
+ sodipodi:role="line">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ id="tspan3896"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="15.867042"
+ x="-14.810593"
+ sodipodi:role="line">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ id="tspan3898"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="23.386896"
+ x="-14.810593"
+ sodipodi:role="line">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ id="tspan3900"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="30.906752"
+ x="-14.810593"
+ sodipodi:role="line">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ id="tspan3902"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="38.426605"
+ x="-14.810593"
+ sodipodi:role="line">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ id="tspan3904"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="45.946461"
+ x="-14.810593"
+ sodipodi:role="line">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ id="tspan3906"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="53.466316"
+ x="-14.810593"
+ sodipodi:role="line">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ id="tspan3908"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="60.986172"
+ x="-14.810593"
+ sodipodi:role="line">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ id="tspan3910"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="68.506027"
+ x="-14.810593"
+ sodipodi:role="line">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ id="tspan3912"
+ style="fill:url(#radialGradient3914);fill-opacity:1"
+ y="76.025879"
+ x="-14.810593"
+ sodipodi:role="line" /></text>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="dark numbers"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <text
+ xml:space="preserve"
+ style="font-size:6.01588392px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;opacity:0.34213125;fill:url(#radialGradient4338);fill-opacity:1;stroke:none;display:inline;font-family:Sans"
+ x="-14.810593"
+ y="-44.291798"
+ id="text3028"
+ sodipodi:linespacing="125%"><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="-44.291798"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4268">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="-36.771942"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4316">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="-29.252089"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4318">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="-21.732233"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4320">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="-14.212379"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4322">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="-6.692523"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4324">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="0.82733178"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4326">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="8.347187"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4328">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="15.867042"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4330">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="23.386896"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4332">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="30.906752"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4334">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="38.426605"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4336">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="45.946461"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4338">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="53.466316"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4340">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="60.986172"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4342">0111010001101111011011110010000001101101011000010110111001111001</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="68.506027"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4344">0010000001110011011001010110001101110010011001010111010001110011</tspan><tspan
+ sodipodi:role="line"
+ x="-14.810593"
+ y="76.025879"
+ style="fill:url(#radialGradient4338);fill-opacity:1"
+ id="tspan4346" /></text>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="shadow"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <path
+ sodipodi:type="arc"
+ style="opacity:0.45;fill:#000000;fill-opacity:0.18811885;stroke:none;display:inline;filter:url(#filter3534)"
+ id="path3370-3"
+ sodipodi:cx="24.445692"
+ sodipodi:cy="38.302536"
+ sodipodi:rx="30.910667"
+ sodipodi:ry="8.485281"
+ d="m 55.356359,38.302536 a 30.910667,8.485281 0 1 1 -61.8213344,0 30.910667,8.485281 0 1 1 61.8213344,0 z"
+ transform="matrix(0.39465578,0,0,0.48810739,13.860321,22.543567)" />
+ <path
+ style="opacity:0.36708865;fill:#000000;fill-opacity:1;stroke:none;display:inline;filter:url(#filter4616)"
+ d="M 33.023528,4.7357771 C 30.529737,3.4025256 27.545966,4.1175282 26.349182,6.3577728 25.920404,7.1603988 25.77762,8.0667557 25.888951,8.9358488 25.463966,7.7649385 24.56033,6.698974 23.313434,6.0323483 20.819643,4.6990958 17.849733,5.4596744 16.652949,7.699919 c -1.196783,2.2402442 -0.162698,5.103306 2.331095,6.436558 1.113897,0.595521 2.327711,0.757214 3.435982,0.577564 l 0.948295,4.590523 -11.640194,21.789126 c 4.727044,2.448842 5.114642,2.650571 9.334368,4.914086 l 1.696867,-3.176343 -5.825301,-3.114372 1.205251,-2.256094 5.825302,3.114371 1.839594,-3.443513 -5.8253,-3.114372 1.284545,-2.404522 5.884742,3.146151 1.839594,-3.443513 -5.884743,-3.14615 3.774341,-7.06514 4.355892,-1.716589 c 0.481968,0.919556 1.237883,1.730396 2.260564,2.27715 2.493792,1.333253 5.49342,0.588563 6.690203,-1.65168 1.196784,-2.240244 0.132978,-5.119197 -2.360814,-6.452449 -1.888875,-1.051279 -4.058212,-0.274755 -3.739182,-0.510677 0.463736,-0.356885 1.011515,-1.321251 1.300292,-1.861808 1.196784,-2.2402443 0.132978,-5.1191965 -2.360814,-6.4524489 z m -1.014949,1.89987 C 32.989122,7.1598721 33.3338,8.3748916 32.8106,9.3542642 32.2874,10.333634 31.086093,10.722127 30.105551,10.197901 29.12501,9.6736762 28.75061,8.4427667 29.27381,7.4633953 29.79701,6.4840229 31.028038,6.1114211 32.008579,6.6356471 z m 4.87689,9.3241679 c 0.980541,0.524226 1.35494,1.755135 0.83174,2.734506 -0.5232,0.979372 -1.754227,1.351974 -2.734769,0.827748 -0.980541,-0.524225 -1.354942,-1.755134 -0.831742,-2.734506 0.523201,-0.979372 1.754229,-1.351973 2.734771,-0.827748 z M 22.110179,8.2131828 c 0.980542,0.524225 1.325221,1.7392444 0.80202,2.7186152 -0.5232,0.979371 -1.724507,1.367864 -2.705048,0.843638 C 19.226609,11.25121 18.85221,10.020302 19.375409,9.0409302 19.89861,8.0615586 21.129637,7.6889568 22.110179,8.2131828 z"
+ id="rect3942-1-1"
+ inkscape:connector-curvature="0"
+ transform="matrix(0.99520415,0,0,0.96572236,-0.53660238,1.3398475)" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer9"
+ inkscape:label="logo"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <path
+ style="fill:url(#linearGradient4152-1);fill-opacity:1;stroke:#c4a000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
+ d="m -48.584821,4.8965848 c -2.34612,-1.2752397 -5.153206,-0.5913479 -6.279122,1.5514202 -0.403388,0.7677025 -0.537717,1.6346224 -0.432978,2.4658999 -0.399819,-1.1199622 -1.249946,-2.1395449 -2.423007,-2.7771647 -2.34612,-1.2752406 -5.140165,-0.547756 -6.266081,1.5950121 -1.125915,2.1427683 -0.153064,4.8812547 2.193058,6.1564947 1.047937,0.569609 2.189875,0.724266 3.232519,0.552433 l 0.892141,4.390782 -10.950914,20.841048 c 4.44713,2.342289 4.811776,2.53524 8.781629,4.700266 l 1.596386,-3.038135 -5.480353,-2.978861 1.133882,-2.157928 5.480353,2.97886 1.730662,-3.29368 -5.480352,-2.978861 1.20848,-2.299898 5.536274,3.009257 1.730662,-3.29368 -5.536275,-3.009256 3.550841,-6.757725 4.097956,-1.641898 c 0.453428,0.879545 1.164581,1.655104 2.126704,2.178068 2.346121,1.275241 5.168124,0.562954 6.294039,-1.579813 1.125916,-2.142767 0.125104,-4.896453 -2.221017,-6.171693 -1.777025,-1.005536 -3.817903,-0.2628 -3.517765,-0.488457 0.436276,-0.341356 0.951618,-1.263761 1.223295,-1.780797 1.125916,-2.1427694 0.125103,-4.8964536 -2.221017,-6.1716942 z m -0.954849,1.8172038 c 0.92248,0.5014151 1.246748,1.6635672 0.754529,2.600326 -0.492218,0.9367564 -1.622389,1.3083454 -2.544868,0.8069294 -0.922478,-0.5014154 -1.274708,-1.6787664 -0.782489,-2.6155238 0.492218,-0.9367584 1.65035,-1.2931478 2.572828,-0.7917316 z m 4.588103,8.9184594 c 0.922478,0.501417 1.274706,1.678767 0.782488,2.615524 -0.492218,0.936758 -1.65035,1.293147 -2.572828,0.791731 -0.922478,-0.501415 -1.274709,-1.678765 -0.78249,-2.615523 0.492219,-0.936758 1.650351,-1.293147 2.57283,-0.791732 z M -58.851931,8.2226832 c 0.922478,0.5014152 1.246747,1.6635674 0.754528,2.6003248 -0.492219,0.936757 -1.62239,1.308346 -2.544868,0.80693 -0.922478,-0.501416 -1.274707,-1.6787654 -0.78249,-2.6155244 0.49222,-0.9367572 1.650351,-1.2931465 2.57283,-0.7917304 z"
+ id="rect3942-1"
+ inkscape:connector-curvature="0" />
+ <path
+ style="fill:url(#linearGradient4710);fill-opacity:1;stroke:#c4a000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
+ d="M 30.976169,5.0522561 C 28.630049,3.7770164 25.822959,4.4609082 24.697049,6.6036763 24.293657,7.3713788 24.159328,8.2382986 24.264067,9.0695761 23.864248,7.9496139 23.014121,6.9300313 21.84106,6.2924115 c -2.34612,-1.2752406 -5.140165,-0.547756 -6.266081,1.595012 -1.125915,2.1427685 -0.153064,4.8812545 2.193058,6.1564945 1.047937,0.569609 2.189875,0.724266 3.232519,0.552433 l 0.892141,4.390782 -10.950914,20.841048 c 4.44713,2.342289 4.811776,2.53524 8.781629,4.700266 l 1.596386,-3.038135 -5.480353,-2.978861 1.133882,-2.157928 5.480353,2.97886 1.730662,-3.29368 -5.480352,-2.978861 1.20848,-2.299898 5.536279,3.009257 1.73066,-3.29368 -5.536278,-3.009256 3.550838,-6.757725 4.09796,-1.641898 c 0.45343,0.879545 1.16458,1.655104 2.1267,2.178068 2.34612,1.275241 5.16813,0.562954 6.29404,-1.579813 1.12592,-2.142767 0.12511,-4.896453 -2.22101,-6.171693 -1.77703,-1.005536 -3.81791,-0.2628 -3.51777,-0.488457 0.43628,-0.341356 0.95162,-1.263761 1.2233,-1.780797 1.12591,-2.1427692 0.1251,-4.8964533 -2.22102,-6.1716939 z m -0.95485,1.8172038 c 0.92248,0.5014151 1.24675,1.6635671 0.75453,2.600326 -0.49222,0.9367561 -1.62239,1.3083451 -2.54487,0.8069291 -0.92248,-0.5014151 -1.27471,-1.6787662 -0.78249,-2.6155236 0.49222,-0.9367583 1.65035,-1.2931477 2.57283,-0.7917315 z m 4.5881,8.9184591 c 0.92248,0.501417 1.27471,1.678767 0.78249,2.615524 -0.49222,0.936758 -1.65035,1.293147 -2.57283,0.791731 -0.92248,-0.501415 -1.27471,-1.678765 -0.78249,-2.615523 0.49222,-0.936758 1.65035,-1.293147 2.57283,-0.791732 z M 20.709057,8.3783544 c 0.922478,0.5014152 1.246747,1.6635676 0.754528,2.6003246 -0.492219,0.936757 -1.62239,1.308346 -2.544868,0.80693 -0.922478,-0.501416 -1.274707,-1.678765 -0.78249,-2.6155242 0.49222,-0.9367572 1.650351,-1.2931465 2.57283,-0.7917304 z"
+ id="rect3942-1-3"
+ inkscape:connector-curvature="0" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="circle"
+ style="display:none"
+ sodipodi:insensitive="true">
+ <path
+ style="fill:#f5f5f5;fill-opacity:1;fill-rule:nonzero;stroke:none;display:inline"
+ d="M 0,0 0,24 C 0,10.745166 10.745166,0 24,0 L 0,0 z M 24,0 C 37.254834,0 48,10.745166 48,24 L 48,0 24,0 z M 48,24 C 48,37.254834 37.254834,48 24,48 l 24,0 0,-24 z M 24,48 C 10.745166,48 0,37.254834 0,24 l 0,24 24,0 z"
+ id="path3026"
+ inkscape:connector-curvature="0" />
+ </g>
+</svg>
diff --git a/docs/_static/masthead.png b/docs/_static/masthead.png
index 5aac437..890b2a1 100644
--- a/docs/_static/masthead.png
+++ b/docs/_static/masthead.png
Binary files differ
diff --git a/docs/_static/masthead.svg b/docs/_static/masthead.svg
index e02eca8..2257bb4 100644
--- a/docs/_static/masthead.svg
+++ b/docs/_static/masthead.svg
@@ -14,10 +14,10 @@
height="52"
id="svg2383"
sodipodi:version="0.32"
- inkscape:version="0.48.3.1 r9886"
+ inkscape:version="0.48.4 r9939"
sodipodi:docname="masthead.svg"
inkscape:output_extension="org.inkscape.output.svg.inkscape"
- inkscape:export-filename="/home/biscuit/dev/libs/passlib/stable/docs/_static/masthead.png"
+ inkscape:export-filename="/home/biscuit/dev/libs/passlib/default/docs/_static/masthead.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90"
version="1.0"
@@ -214,11 +214,11 @@
xlink:href="#linearGradient3910"
id="radialGradient4270"
gradientUnits="userSpaceOnUse"
- gradientTransform="matrix(-1.9278492,0.04803928,-0.03632715,-1.4578329,160.96275,83.618729)"
- cx="67.387276"
- cy="44.127342"
- fx="67.387276"
- fy="44.127342"
+ gradientTransform="matrix(-3.8410307,-0.00823075,0.00312485,-1.4582821,288.14593,87.430435)"
+ cx="65.911835"
+ cy="40.810707"
+ fx="65.911835"
+ fy="40.810707"
r="21.542249" />
</defs>
<sodipodi:namedview
@@ -229,16 +229,16 @@
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="2.4748737"
- inkscape:cx="68.856099"
+ inkscape:cx="23.601264"
inkscape:cy="26.997913"
inkscape:current-layer="layer6"
showgrid="true"
inkscape:grid-bbox="true"
inkscape:document-units="px"
inkscape:window-width="1920"
- inkscape:window-height="1021"
- inkscape:window-x="0"
- inkscape:window-y="0"
+ inkscape:window-height="1020"
+ inkscape:window-x="1920"
+ inkscape:window-y="33"
borderlayer="true"
inkscape:window-maximized="1" />
<metadata
@@ -365,7 +365,8 @@
inkscape:groupmode="layer"
id="layer3"
inkscape:label="logo"
- style="display:inline">
+ style="display:inline"
+ sodipodi:insensitive="true">
<path
style="fill:url(#linearGradient4152);fill-opacity:1;stroke:#c4a000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
d="M 30.198981,4.7423018 C 27.603462,3.3551933 24.497977,4.0990787 23.252373,6.4298176 22.806104,7.264865 22.657496,8.207834 22.773368,9.1120344 22.331047,7.8938257 21.39055,6.7848017 20.09279,6.0912475 17.497271,4.7041388 14.406213,5.4954406 13.16061,7.8261789 c -1.245603,2.3307391 -0.169335,5.3094541 2.426184,6.6965631 1.159336,0.619577 2.422664,0.787802 3.576144,0.600895 l 0.986978,4.775957 -12.1150241,22.669294 c 4.9198721,2.547763 5.3232811,2.757641 9.7151381,5.11259 l 1.766086,-3.304651 -6.062927,-3.240176 1.254416,-2.347229 6.062928,3.240176 1.914635,-3.582614 -6.062927,-3.240176 1.336944,-2.501653 6.124795,3.27324 1.914635,-3.582613 -6.124795,-3.273239 3.928305,-7.350535 4.533578,-1.785931 c 0.501629,0.956702 1.28838,1.800296 2.352778,2.369136 2.595519,1.387109 5.717509,0.612338 6.963112,-1.7184 1.245603,-2.330738 0.138402,-5.325986 -2.457117,-6.713095 -1.965927,-1.093746 -4.223756,-0.285853 -3.891712,-0.531306 0.482653,-0.371302 1.052777,-1.374623 1.353334,-1.937016 C 33.901701,9.1246576 32.7945,6.1294104 30.198981,4.7423018 z M 29.14263,6.7189161 c 1.020541,0.5454016 1.37928,1.809501 0.834737,2.8284351 C 29.432824,10.566283 28.182513,10.970469 27.161973,10.425067 26.141433,9.8796667 25.751761,8.5990343 26.296303,7.5801015 26.840846,6.5611674 28.12209,6.1735148 29.14263,6.7189161 z m 5.075829,9.7008179 c 1.02054,0.545402 1.410211,1.826033 0.865669,2.844966 -0.544543,1.018933 -1.825786,1.406587 -2.846327,0.861185 -1.020539,-0.545401 -1.410213,-1.826033 -0.86567,-2.844966 0.544543,-1.018934 1.825788,-1.406586 2.846328,-0.861185 z M 18.840451,8.360176 c 1.020541,0.5454016 1.37928,1.809501 0.834737,2.828434 -0.544543,1.018932 -1.794854,1.423118 -2.815394,0.877716 -1.02054,-0.545401 -1.410212,-1.826032 -0.86567,-2.8449654 0.544543,-1.0189336 1.825787,-1.406586 2.846327,-0.8611846 z"
@@ -376,7 +377,8 @@
inkscape:groupmode="layer"
id="layer8"
inkscape:label="title shadow"
- style="display:inline">
+ style="display:inline"
+ sodipodi:insensitive="true">
<text
xml:space="preserve"
style="font-size:46.00891495px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;opacity:0.31538463;fill:#000000;fill-opacity:1;stroke:none;display:inline;filter:url(#filter3883);font-family:Crimson Text;-inkscape-font-specification:Crimson Text"
@@ -393,17 +395,19 @@
<g
inkscape:groupmode="layer"
id="layer1"
- style="display:inline">
+ style="display:inline"
+ sodipodi:insensitive="true"
+ inkscape:label="title">
<text
xml:space="preserve"
- style="font-size:46.00891495px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;font-family:Crimson Text;-inkscape-font-specification:Crimson Text"
- x="41.064335"
- y="39.446754"
+ style="font-size:39.73984528px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;font-family:Noticia Text;-inkscape-font-specification:Noticia Text"
+ x="40.886795"
+ y="39.110039"
id="text2740"
sodipodi:linespacing="125%"><tspan
sodipodi:role="line"
id="tspan2742"
- x="41.064335"
- y="39.446754">PassLib</tspan></text>
+ x="40.886795"
+ y="39.110039">PassLib</tspan></text>
</g>
</svg>
diff --git a/docs/conf.py b/docs/conf.py
index 5164e8a..9ddc460 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -51,8 +51,8 @@ extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.todo',
- # add autodoc support for ReST sections in class/function docstrings
- 'cloud_sptheme.ext.autodoc_sections',
+ # 3rd part extensions
+ 'sphinxcontrib.fulltoc',
# adds extra ids & classes to genindex html, for additional styling
'cloud_sptheme.ext.index_styling',
@@ -60,9 +60,6 @@ extensions = [
# inserts toc into right hand nav bar (ala old style python docs)
'cloud_sptheme.ext.relbar_toc',
- # replace sphinx :samp: role handler with one that allows escaped {} chars
- 'cloud_sptheme.ext.escaped_samp_literals',
-
# add "issue" role
'cloud_sptheme.ext.issue_tracker',
@@ -71,6 +68,12 @@ extensions = [
# modify logo per page
'cloud_sptheme.ext.perpage',
+
+ # monkeypatch sphinx to support a few extra things we can't do with extensions.
+ 'cloud_sptheme.ext.autodoc_sections',
+ 'cloud_sptheme.ext.autoattribute_search_bases',
+ 'cloud_sptheme.ext.docfield_markup',
+ 'cloud_sptheme.ext.escaped_samp_literals',
]
# Add any paths that contain templates here, relative to this directory.
@@ -101,6 +104,8 @@ copyright = "2008-2015, " + author
# version: The short X.Y version.
from passlib import __version__ as release
version = csp.get_version(release)
+tags.add("devcopy")
+devcopy = '.dev' in release
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@@ -168,6 +173,7 @@ if csp.is_cloud_theme(html_theme):
borderless_decor=True,
sidebar_localtoc_title="Page contents",
max_width="12in",
+ sidebarwidth="3.5in",
hyphenation_language="en",
)
if 'for-pypi' in options:
@@ -196,7 +202,7 @@ perpage_html_logo = {
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
-html_favicon = "logo.ico"
+html_favicon = os.path.join("_static", "logo.ico")
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
@@ -212,14 +218,14 @@ html_static_path = ['_static']
html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
-common_sidebars = ['quicklinks.html', 'searchbox.html']
-html_sidebars = {
- '**': ['localtoc.html', 'relations.html'] + common_sidebars,
- 'py-modindex': common_sidebars,
- 'genindex': common_sidebars,
- 'search': common_sidebars,
-}
-#html_sidebars = {'**': ['globaltoc.html', 'searchbox.html']}
+# common_sidebars = ['quicklinks.html', 'searchbox.html']
+# html_sidebars = {
+# '**': ['localtoc.html', 'relations.html'] + common_sidebars,
+# 'py-modindex': common_sidebars,
+# 'genindex': common_sidebars,
+# 'search': common_sidebars,
+# }
+html_sidebars = {'**': ['globaltoc.html', 'searchbox.html']}
# Additional templates that should be rendered to pages, maps page names to
# template names.
diff --git a/docs/contents.rst b/docs/contents.rst
index 099c5f8..3ad2ced 100644
--- a/docs/contents.rst
+++ b/docs/contents.rst
@@ -20,6 +20,8 @@ Table Of Contents
lib/passlib.apache
lib/passlib.ext.django
+ lib/passlib.pwd
+ lib/passlib.totp
lib/passlib.exc
lib/passlib.registry
diff --git a/docs/dev-requirements.txt b/docs/dev-requirements.txt
new file mode 100644
index 0000000..f49e8f6
--- /dev/null
+++ b/docs/dev-requirements.txt
@@ -0,0 +1 @@
+hg+https://bitbucket.org/ecollins/cloud_sptheme
diff --git a/docs/index.rst b/docs/index.rst
index fdb3d51..a776bd6 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -5,6 +5,14 @@
Passlib |release| documentation
==========================================
+.. only:: devcopy
+
+ .. warning::
+
+ This is the documentation for a development version of Passlib.
+ For documentation of the latest stable version,
+ see `<https://pythonhosted.com/passlib>`_.
+
Welcome
=======
Passlib is a password hashing library for Python 2 & 3, which provides
@@ -33,9 +41,9 @@ using the :doc:`SHA256-Crypt </lib/passlib.hash.sha256_crypt>` algorithm::
Content Summary
===============
-.. rst-class:: floater
+.. rst-class:: float-right inline-title
-.. seealso:: :ref:`What's new in Passlib 1.6.3 <whats-new>`
+.. seealso:: :ref:`What's new in Passlib 1.7 <whats-new>`
Introductory Materials
----------------------
@@ -88,6 +96,12 @@ Application Helpers
:mod:`passlib.ext.django`
Django plugin which monkeypatches support for (almost) any hash in Passlib.
+ :mod:`passlib.pwd`
+ Password generation helpers.
+
+ :mod:`passlib.totp`
+ TOTP / Two Factor Authentication
+
..
Support Modules
---------------
diff --git a/docs/install.rst b/docs/install.rst
index 3e98992..ece2198 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -6,24 +6,24 @@ Installation
Supported Platforms
===================
-Passlib requires Python 2 (>= 2.5) or Python 3.
+Passlib requires Python 2 (>= 2.6) or Python 3 (>= 3.2).
It is known to work with the following Python implementations:
-* CPython 2 -- v2.5 or newer.
-* CPython 3 -- all versions.
-* PyPy -- v1.5 or newer.
-* PyPy3 -- v2.1 or newer.
-* Jython -- v2.5 or newer.
+* CPython 2 -- v2.6 or newer.
+* CPython 3 -- v3.2 or newer.
+* PyPy -- v2.0 or newer.
+* PyPy3 -- v2.0 or newer.
+* Jython -- v2.7 or newer.
Passlib should work with all operating systems and environments,
-as it contains builtin fallbacks
-for almost all OS-dependant features.
+as it contains builtin fallbacks for almost all OS-dependant features.
Google App Engine is supported as well.
-.. warning::
+.. versionchanged:: 1.7
- **Passlib 1.7 will drop support for Python 2.5, 3.0, and 3.1**;
- and will require Python 2.6 / 3.2 or newer.
+ Support for Python 2.5, 3.0, and 3.1 was dropped.
+ Support for PyPy 1.x was dropped.
+ Support for Python 3.2 may be dropped in the next major release.
.. _optional-libraries:
@@ -70,7 +70,8 @@ which provide nearly complete coverage, and verification of the hash
algorithms using multiple external sources (if detected at runtime).
All unit tests are contained within the :mod:`passlib.tests` subpackage,
and are designed to be run using the
-`Nose <http://somethingaboutorange.com/mrl/projects/nose>`_ unit testing library.
+`Nose <http://somethingaboutorange.com/mrl/projects/nose>`_ unit testing library
+(as well as the ``unittest2`` library under Python 2.6).
Once Passlib and Nose have been installed, the main suite of tests may be run from the source directory::
diff --git a/docs/lib/passlib.context.rst b/docs/lib/passlib.context.rst
index 6d40067..4043aac 100644
--- a/docs/lib/passlib.context.rst
+++ b/docs/lib/passlib.context.rst
@@ -163,8 +163,11 @@ Options which directly affect the behavior of the CryptContext instance:
or fractional number of seconds.
.. deprecated:: 1.6
- This option has not proved very useful, and will
- be removed in version 1.8.
+ This option has not proved very useful, is ignored by 1.7,
+ and will be removed in version 1.8.
+
+ .. versionchanged:: 1.7
+ Per deprecation roadmap above, this option is now ignored.
.. _context-algorithm-options:
diff --git a/docs/lib/passlib.exc.rst b/docs/lib/passlib.exc.rst
index e30338a..f40d6f3 100644
--- a/docs/lib/passlib.exc.rst
+++ b/docs/lib/passlib.exc.rst
@@ -19,6 +19,8 @@ Exceptions
.. autoexception:: PasslibSecurityError
+.. autoexception:: TokenReuseError
+
Warnings
========
.. autoexception:: PasslibWarning
diff --git a/docs/lib/passlib.ext.django.rst b/docs/lib/passlib.ext.django.rst
index 7e00f69..28e7a3b 100644
--- a/docs/lib/passlib.ext.django.rst
+++ b/docs/lib/passlib.ext.django.rst
@@ -8,10 +8,9 @@
.. versionadded:: 1.6
-.. note::
+.. versionchanged:: 1.7
- Passlib 1.7's :mod:`passlib.ext.django` extension will drop
- support for Django 1.5 and earlier, and require Django 1.6 or newer.
+ As of Passlib 1.7, this module requires Django 1.6 or newer.
This module contains a `Django <http://www.djangoproject.com>`_ plugin which
overrides all of Django's password hashing functions, replacing them
@@ -41,7 +40,7 @@ of uses:
This plugin should be considered "release candidate" quality.
It works, and has good unittest coverage, but has seen only
limited real-world use. Please report any issues.
- It has been tested with Django 0.9.6 - 1.4.
+ It has been tested with Django 1.6 - 1.8.
Installation
=============
diff --git a/docs/lib/passlib.hash.cisco_asa.rst b/docs/lib/passlib.hash.cisco_asa.rst
new file mode 100644
index 0000000..eb0b643
--- /dev/null
+++ b/docs/lib/passlib.hash.cisco_asa.rst
@@ -0,0 +1,14 @@
+.. index:: Cisco; ASA hash
+
+==================================================================
+:class:`passlib.hash.cisco_asa` - Cisco ASA hash
+==================================================================
+
+.. currentmodule:: passlib.hash
+
+.. versionadded:: 1.7
+
+.. seealso::
+
+ The :class:`!cisco_asa` handler provides support for the 2005 revision of the older :class:`!cisco_pix` hash.
+ The usage, functionality, and format is the same. It's documented on the :doc:`cisco_pix page <passlib.hash.cisco_pix>`.
diff --git a/docs/lib/passlib.hash.cisco_pix.rst b/docs/lib/passlib.hash.cisco_pix.rst
index 01b8f7b..bb29d68 100644
--- a/docs/lib/passlib.hash.cisco_pix.rst
+++ b/docs/lib/passlib.hash.cisco_pix.rst
@@ -8,8 +8,16 @@
.. warning::
- This hash is not secure, and should not be used for any purposes
- besides manipulating existing Cisco PIX password hashes.
+ These hashes are not secure, and should not be used for any purposes
+ besides manipulating existing hashes.
+
+.. todo::
+
+ Passlib currently lack a thorough set of test cases for the :class:`cisco_asa` hash
+ For people with access to such a system, verifying passlib's reference vectors
+ would be a great help (see :issue:`51`).
+ In the mean time, there are no guarantees that its behavior correctly replicates
+ the official implementation. *caveat emptor*.
.. warning::
@@ -19,8 +27,9 @@
.. currentmodule:: passlib.hash
-This class implements the password hash algorithm commonly found on Cisco
-PIX firewalls. This class can be used directly as follows::
+The :class:`cisco_pix` class implements the password hash algorithm commonly found on older Cisco
+PIX firewalls. The :class:`cisco_asa` class implements a newer variant found Cisco ASA 7.0 and
+newer systems. They can be used directly as follows::
>>> from passlib.hash import cisco_pix as pix
@@ -52,11 +61,14 @@ PIX firewalls. This class can be used directly as follows::
Interface
=========
+
.. autoclass:: cisco_pix()
+.. autoclass:: cisco_asa()
+
.. note::
- This hash algorithm has a context-sensitive peculiarity.
+ These hash algorithms have a context-sensitive peculiarity.
It takes in an optional username, used to salt the hash,
but with specific restrictions...
@@ -71,25 +83,36 @@ Interface
Format & Algorithm
==================
-Cisco PIX hashes consist of a 12 byte digest, encoded as a 16 character
+Cisco PIX / ASA hashes consist of a 12 byte digest, encoded as a 16 character
:data:`HASH64 <passlib.utils.h64>`-encoded string. An example
hash (of ``"password"``) is ``"NuLKvvWGg.x9HEKO"``.
-The digest is calculated as follows:
+The PIX / ASA digests are calculated as follows:
1. The password is encoded using an ``ASCII``-compatible encoding
(all known references are strict 7-bit ascii, and Passlib uses ``UTF-8``
to provide unicode support).
+
2. If the hash is associated with a user account,
append the first four bytes of the user account name
to the end of the password. If the hash is NOT associated
with a user account (e.g. it's the "enable" password),
this step should be omitted.
-3. The resulting password should be truncated to 16 bytes,
- or the right side NULL padded to 16 bytes, as appropriate.
+
+ For :class:`!cisco_asa`,
+ this step is omitted if the password is 28 bytes or more.
+
+3. The password should be truncated to 16 bytes, or the right side NULL
+ padded to 16 bytes, as appropriate.
+
+ For :class:`!cisco_asa`,
+ if the password is 13 or more bytes, the truncate/padding size is increased to 32 bytes.
+
4. Run the result of step 3 through MD5.
+
5. Discard every 4th byte of the 16-byte MD5 hash, starting
with the 4th byte.
+
6. Encode the 12-byte result using :data:`HASH64 <passlib.utils.h64>`.
Security Issues
@@ -106,7 +129,7 @@ Cisco PIX hashes, due to the following flaws:
additionally limits the keyspace, and the effectiveness of the username
as a salt; making pre-computed and brute force attacks much more feasible.
-* Since the keyspace of ``user+password`` is still a subset of ascii characters,
+* Since the keyspace of ``password+user`` is still a subset of ascii characters,
existing MD5 lookup tables have an increased chance of being able to
reverse common hashes.
diff --git a/docs/lib/passlib.hash.rst b/docs/lib/passlib.hash.rst
index c4d7574..3dcda1c 100644
--- a/docs/lib/passlib.hash.rst
+++ b/docs/lib/passlib.hash.rst
@@ -247,6 +247,7 @@ in one of the above categories:
:maxdepth: 1
passlib.hash.cisco_pix
+ passlib.hash.cisco_asa
* *Cisco "Type 5" hashes* - see :doc:`md5_crypt <passlib.hash.md5_crypt>`
diff --git a/docs/lib/passlib.pwd.rst b/docs/lib/passlib.pwd.rst
new file mode 100644
index 0000000..48441df
--- /dev/null
+++ b/docs/lib/passlib.pwd.rst
@@ -0,0 +1,48 @@
+.. module:: passlib.pwd
+ :synopsis: password generation helpers
+
+=================================================
+:mod:`passlib.pwd` -- password generation helpers
+=================================================
+
+.. versionadded:: 1.7
+
+.. todo::
+ This module is still a work in progress, it's API may change
+ before release. See module source for detailed todo list.
+
+Generation
+==========
+.. warning::
+
+ Before using these routines, be sure your system's RNG state is safe,
+ and that you use a sufficiently high ``entropy`` value for
+ the intended purpose.
+
+.. autofunction:: generate(size=None, entropy=None, count=None, preset=None, charset=None, wordset=None, spaces=True)
+
+.. rst-class:: html-toggle
+
+Generator Backends
+------------------
+The following classes are used by the :func:`generate` function behind the scenes,
+to perform word- and phrase- generation. They are useful for folks who want
+a little more information about the password generation process, and/or
+want to use a preconfigured generator.
+
+.. autoclass:: SecretGenerator
+.. autoclass:: WordGenerator
+.. autoclass:: PhraseGenerator
+
+Analysis
+========
+.. warning::
+
+ *Disclaimer:*
+ There can be no accurate estimate of the quality of a password,
+ because it depends on too many conditions that are unknowable from just
+ looking at the password. This code attempts to rule out the worst passwords,
+ and identify potentially-weak passwords, but should be used only as a guide.
+
+.. autofunction:: strength
+.. autofunction:: classify
diff --git a/docs/lib/passlib.totp.rst b/docs/lib/passlib.totp.rst
new file mode 100644
index 0000000..2b4aae8
--- /dev/null
+++ b/docs/lib/passlib.totp.rst
@@ -0,0 +1,76 @@
+.. module:: passlib.totp
+ :synopsis: totp / two factor authentaction
+
+=======================================================
+:mod:`passlib.totp` -- TOTP / Two Factor Authentication
+=======================================================
+
+.. versionadded:: 1.7
+
+.. todo::
+
+ This module is still a work in progress, it's API may change before release.
+
+ Things left:
+
+ * finish unittests (there are a few cases left)
+ * write narrative documentation
+ * get api documentation formatted better (whether by getting nested sections integrated into TOC,
+ or splitting nested sections out into separate sections / pages).
+ * probably want a "beta" release of passlib so people can test this a bit before 1.7.0.
+
+ Optional:
+
+ * more verification against other TOTP servers & clients.
+ * consider native pyqrcode integration (e.g. a ``to_qrcode()`` method)
+
+.. rst-class:: emphasize-children
+
+API Reference
+=============
+
+Common Interface
+----------------
+.. autoclass:: BaseOTP()
+
+TOTP (Timed-based tokens)
+-------------------------
+.. autoclass:: TOTP(key=None, format="base32", \*, new=False, \*\*kwds)
+
+Helper Classes
+..............
+
+.. autoclass:: TotpToken()
+
+.. autoclass:: TotpMatch()
+
+HOTP (Counter-based tokens)
+---------------------------
+.. note::
+
+ HOTP is used much less frequently, since it's fragile
+ (as it's much easier for the server & client to get out of sync in their token
+ count). Unless you have a particular reason, you probably want :class:`TOTP` instead.
+
+.. autoclass:: HOTP(key=None, format="base32", \*, new=False, \*\*kwds)
+
+Helper Classes
+..............
+
+.. autoclass:: HotpMatch()
+
+Deviations
+==========
+
+* The TOTP Spec [#totpspec]_ includes an potentially offset from the base time (``T0``).
+ Passlib omits this (fixing it at ``0``), but so do pretty much all other TOTP implementations.
+
+.. rubric:: Footnotes
+
+.. [#hotpspec] HOTP Specification - :rfc:`4226`
+
+.. [#totpspec] TOTP Specification - :rfc:`6238`
+
+.. [#uriformat] Google's OTPAuth URI format -
+ `<https://code.google.com/p/google-authenticator/wiki/KeyUriFormat>`_
+
diff --git a/docs/lib/passlib.utils.pbkdf2.rst b/docs/lib/passlib.utils.pbkdf2.rst
index f8eb89f..7fa9f98 100644
--- a/docs/lib/passlib.utils.pbkdf2.rst
+++ b/docs/lib/passlib.utils.pbkdf2.rst
@@ -26,7 +26,10 @@ PKCS#5 Key Derivation Functions
Helper Functions
================
.. autofunction:: norm_hash_name
+.. autofunction:: get_hash_info
+
.. autofunction:: get_prf
+.. autofunction:: get_keyed_prf
..
given how this module is expanding in scope,
diff --git a/docs/requirements.txt b/docs/requirements.txt
index f49e8f6..5afb30c 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1 +1,2 @@
+sphinxcontrib-fulltoc
hg+https://bitbucket.org/ecollins/cloud_sptheme
diff --git a/passlib/__init__.py b/passlib/__init__.py
index 0d2dfb2..2d79dfe 100644
--- a/passlib/__init__.py
+++ b/passlib/__init__.py
@@ -1,3 +1,3 @@
"""passlib - suite of password hashing & generation routines"""
-__version__ = '1.6.5'
+__version__ = '1.7.dev0'
diff --git a/passlib/_data/beale.wordset.z b/passlib/_data/beale.wordset.z
new file mode 100644
index 0000000..f94b381
--- /dev/null
+++ b/passlib/_data/beale.wordset.z
Binary files differ
diff --git a/passlib/_data/diceware.wordset.z b/passlib/_data/diceware.wordset.z
new file mode 100644
index 0000000..675e792
--- /dev/null
+++ b/passlib/_data/diceware.wordset.z
Binary files differ
diff --git a/passlib/_data/electrum.wordset.z b/passlib/_data/electrum.wordset.z
new file mode 100644
index 0000000..f22575a
--- /dev/null
+++ b/passlib/_data/electrum.wordset.z
Binary files differ
diff --git a/passlib/_setup/stamp.py b/passlib/_setup/stamp.py
index 8a68658..d5e559f 100644
--- a/passlib/_setup/stamp.py
+++ b/passlib/_setup/stamp.py
@@ -6,7 +6,6 @@ from __future__ import with_statement
# core
import os
import re
-import time
from distutils.dist import Distribution
# pkg
# local
diff --git a/passlib/apache.py b/passlib/apache.py
index 516deeb..cb166b9 100644
--- a/passlib/apache.py
+++ b/passlib/apache.py
@@ -5,19 +5,16 @@
#=============================================================================
from __future__ import with_statement
# core
-from hashlib import md5
import logging; log = logging.getLogger(__name__)
import os
-import sys
from warnings import warn
# site
# pkg
from passlib.context import CryptContext
from passlib.exc import ExpectedStringError
from passlib.hash import htdigest
-from passlib.utils import consteq, render_bytes, to_bytes, deprecated_method, is_ascii_codec
-from passlib.utils.compat import b, bytes, join_bytes, str_to_bascii, u, \
- unicode, BytesIO, iteritems, imap, PY3
+from passlib.utils import render_bytes, to_bytes, deprecated_method, is_ascii_codec
+from passlib.utils.compat import join_bytes, unicode, BytesIO, iteritems, PY3, OrderedDict
# local
__all__ = [
'HtpasswdFile',
@@ -29,44 +26,10 @@ __all__ = [
#=============================================================================
_UNSET = object()
-_BCOLON = b(":")
+_BCOLON = b":"
# byte values that aren't allowed in fields.
-_INVALID_FIELD_CHARS = b(":\n\r\t\x00")
-
-#=============================================================================
-# backport of OrderedDict for PY2.5
-#=============================================================================
-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
+_INVALID_FIELD_CHARS = b":\n\r\t\x00"
#=============================================================================
# common helpers
diff --git a/passlib/context.py b/passlib/context.py
index fd228ff..e3b2153 100644
--- a/passlib/context.py
+++ b/passlib/context.py
@@ -4,25 +4,18 @@
#=============================================================================
from __future__ import with_statement
# core
-from functools import update_wrapper
-import inspect
import re
-import hashlib
-from math import log as logb, ceil
import logging; log = logging.getLogger(__name__)
-import os
-import re
-from time import sleep
from warnings import warn
# site
# pkg
-from passlib.exc import PasslibConfigWarning, ExpectedStringError, ExpectedTypeError
+from passlib.exc import ExpectedStringError, ExpectedTypeError
from passlib.registry import get_crypt_handler, _validate_handler_name
-from passlib.utils import rng, tick, to_bytes, deprecated_method, \
+from passlib.utils import handlers as uh, to_bytes, deprecated_method, \
to_unicode, splitcomma
-from passlib.utils.compat import bytes, iteritems, num_types, \
- PY2, PY3, PY_MIN_32, unicode, SafeConfigParser, \
- NativeStringIO, BytesIO, base_string_types, native_string_types
+from passlib.utils.compat import iteritems, num_types, \
+ PY2, PY3, unicode, SafeConfigParser, \
+ NativeStringIO, BytesIO, unicode_or_bytes_types, native_string_types
# local
__all__ = [
'CryptContext',
@@ -408,11 +401,14 @@ class CryptPolicy(object):
.. deprecated:: 1.6
min_verify_time will be removed entirely in passlib 1.8
+
+ .. versionchanged:: 1.7
+ this method now always returns 0.
"""
- warn("get_min_verify_time() and min_verify_time option is deprecated, "
+ warn("get_min_verify_time() and min_verify_time option is deprecated and ignored, "
"and will be removed in Passlib 1.8", DeprecationWarning,
stacklevel=2)
- return self._context._config.get_context_option_with_flag(category, "min_verify_time")[0] or 0
+ return 0
def get_options(self, name, category=None):
"""return dictionary of options specific to a given handler.
@@ -575,6 +571,11 @@ class _CryptRecord(object):
this class takes in all the options for a particular (scheme, category)
combination, and attempts to provide as short a code-path as possible for
the particular configuration.
+
+ .. note::
+
+ This is a very thin metadata wrapper around PasswordHash.using(),
+ and may go away eventually.
"""
#===================================================================
@@ -582,56 +583,67 @@ class _CryptRecord(object):
#===================================================================
# informational attrs
- handler = None # handler instance this is wrapping
+ handler = None # base handler instance this is based off of -- could implement hash.base instead.
+ custom_handler = None # handler instance configured w/ appropriate settings
category = None # user category this applies to
deprecated = False # set if handler itself has been deprecated in config
- # 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 # options to be passed directly to encrypt()
-
- # verify() attrs
- _min_verify_time = None
-
- # needs_update() attrs
- _needs_update = None # optional callable provided by handler
- _has_rounds_introspection = False # if rounds can be extract from hash
-
- # cloned directly from handler, not affected by config options.
+ # cloned from custom_handler.
+ genconfig = None
+ encrypt = None
+ verify = None
identify = None
genhash = None
+ needs_update = None # may be overridden if deprecated=True
#===================================================================
# init
#===================================================================
- def __init__(self, handler, category=None, deprecated=False,
- min_rounds=None, max_rounds=None, default_rounds=None,
- vary_rounds=None, min_verify_time=None,
- **settings):
+ def __init__(self, handler, category=None, deprecated=False, **settings):
+
+ # historically, configs may specify generic default rounds.
+ # stripping those out for hashes w/o a rounds parameter,
+ # but need to discourage this situation in the future.
+ if 'rounds' not in handler.setting_kwds:
+ for key in uh.HasRounds.using_rounds_kwds:
+ settings.pop(key, None)
+
+ # create custom handler if needed.
+ if settings:
+ try:
+ custom_handler = handler.using(**settings)
+ except TypeError as err:
+ m = re.match(r".* unexpected keyword argument '(.*)'$", str(err))
+ if m and m.group(1) in settings:
+ # translate into KeyError, for backwards compat.
+ # XXX: push this down to GenericHandler.using() implementation?
+ key = m.group(1)
+ raise KeyError("keyword not supported by %s handler: %r" %
+ (handler.name, key))
+ raise
+ else:
+ custom_handler = handler
+
# store basic bits
self.handler = handler
- self.category = category
+ self.custom_handler = custom_handler
+ self.category = category # XXX: could pass this & deprecated to custom handler.
self.deprecated = deprecated
- self.settings = settings
- # validate & normalize rounds options
- self._init_rounds_options(min_rounds, max_rounds, default_rounds,
- vary_rounds)
-
- # init wrappers for handler methods we modify args to
- self._init_encrypt_and_genconfig()
- self._init_verify(min_verify_time)
- self._init_needs_update()
+ # init needs_update proxy
+ # XXX: could probably do away with entire _CryptRecord -- just need to
+ # monkeypatch .needs_update for our subclass
+ if deprecated:
+ self.needs_update = lambda hash, secret=None: True
+ else:
+ self.needs_update = custom_handler.needs_update
# these aren't wrapped by _CryptRecord, copy them directly from handler.
- self.identify = handler.identify
- self.genhash = handler.genhash
+ self.genconfig = custom_handler.genconfig
+ self.encrypt = custom_handler.encrypt
+ self.verify = custom_handler.verify
+ self.identify = custom_handler.identify
+ self.genhash = custom_handler.genhash
#===================================================================
# virtual attrs
@@ -640,328 +652,11 @@ class _CryptRecord(object):
def scheme(self):
return self.handler.name
- @property
- def _errprefix(self):
- """string used to identify record in error messages"""
- handler = self.handler
- category = self.category
- if category:
- return "%s %s config" % (handler.name, category)
- else:
- return "%s config" % (handler.name,)
-
def __repr__(self): # pragma: no cover -- debugging
- return "<_CryptRecord 0x%x for %s>" % (id(self), self._errprefix)
-
- #===================================================================
- # rounds generation & limits - used by encrypt & deprecation code
- #===================================================================
- 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 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._errprefix, name, hmn, value), PasslibConfigWarning)
- if hmx is not None and value > hmx:
- warn("%s: %s value is above handler maximum %d: %d" %
- (self._errprefix, name, hmx, value), PasslibConfigWarning)
-
- #----------------------------------------------------
- # set policy limits
- #----------------------------------------------------
- if mn is not None:
- if mn < 0:
- 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._errprefix)
- elif mx < 0:
- 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._errprefix)
- if mx is not None and df > mx:
- raise ValueError("%s: default_rounds must be "
- "<= 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
-
- # 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)
-
- #----------------------------------------------------
- # validate vary_rounds,
- # coerce df/vr to linear scale,
- # and setup scale_value() to undo coercion
- #----------------------------------------------------
- # 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,
- # and redefine scale_value() to convert back to log2.
- df = 1<<df
- def scale_value(value, upper):
- if value <= 0:
- return 0
- elif upper:
- return int(logb(value,2))
- else:
- 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:
- self._generate_rounds = lambda: upper
- else:
- assert lower < upper
- self._generate_rounds = lambda: rng.randint(lower, upper)
-
- # 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 _init_encrypt_and_genconfig(self):
- """initialize genconfig/encrypt wrapper methods"""
- settings = self.settings
- handler = self.handler
-
- # check no invalid settings are being set
- keys = handler.setting_kwds
- for key in settings:
- if key not in keys:
- raise KeyError("keyword not supported by %s handler: %r" %
- (handler.name, key))
-
- # 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):
- """add default values to settings for encrypt & genconfig"""
- # load in default values for any settings
- 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_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._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._errprefix, mx, rounds), PasslibConfigWarning, 4)
- rounds = mx
- kwds['rounds'] = rounds
-
- #===================================================================
- # verify()
- #===================================================================
- # 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 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
- self.verify = self.handler.verify
-
- def verify(self, secret, hash, **context):
- """verify helper - adds min_verify_time delay"""
- mvt = self._min_verify_time
- assert mvt > 0, "wrapper should have been replaced for mvt=0"
- start = tick()
- 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("CryptContext: verify exceeded min_verify_time: "
- "scheme=%r min_verify_time=%r elapsed=%r" %
- (self.scheme, mvt, end-start), PasslibConfigWarning)
- return False
-
- #===================================================================
- # needs_update()
- #===================================================================
- def _init_needs_update(self):
- """initialize state for needs_update()"""
- # if handler has been deprecated, replace wrapper and skip other checks
- if self.deprecated:
- self.needs_update = lambda hash, secret: True
- return
-
- # let handler detect hashes with configurations that don't match
- # current settings. currently do this by calling
- # ``handler._bind_needs_update(**settings)``, which if defined
- # should return None or a callable ``needs_update(hash,secret)->bool``.
- #
- # 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, "_bind_needs_update", None)
- if const:
- self._needs_update = const(**self.settings)
-
- # XXX: what about a "min_salt_size" deprecator?
-
- # set flag if we can extract rounds from hash, allowing
- # 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 needs_update(self, hash, secret):
- # 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.
- check = self._needs_update
- if check and check(hash, secret):
- return True
-
- # XXX: should we use from_string() call below to check
- # for config strings, and flag them as needing update?
- # or throw an error?
- # or leave that as an explicitly undefined border case,
- # to keep the codepath simpler & faster?
-
- # if we can parse rounds parameter, check if it's w/in bounds.
- if self._has_rounds_introspection:
- # XXX: this might be a good place to use parsehash()
- hash_obj = self.handler.from_string(hash)
- try:
- rounds = hash_obj.rounds
- except AttributeError: # pragma: no cover -- sanity check
- # XXX: all builtin hashes should have rounds attr,
- # so should a warning be issues here?
- pass
- else:
- mn = self._min_rounds
- if mn is not None and rounds < mn:
- return True
- mx = self._max_rounds
- if mx and rounds > mx:
- return True
-
- return False
+ name = self.handler.name
+ if self.category:
+ name = "%s %s" % (name, self.category)
+ return "<_CryptRecord 0x%x for %s config>" % (id(self), name)
#===================================================================
# eoc
@@ -1095,6 +790,8 @@ class _CryptConfig(object):
raise KeyError("'schemes' context option is not allowed "
"per category")
key, value = norm_context_option(key, value)
+ if key == "min_verify_time": # ignored in 1.7, to be removed in 1.8
+ continue
# store in context_options
# map structure: context_options[key][category] = value
@@ -1153,11 +850,8 @@ class _CryptConfig(object):
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 "
+ warn("'min_verify_time' was deprecated in Passlib 1.6, is "
"ignored in 1.7, and removed in 1.8.", DeprecationWarning)
- value = float(value)
- if value < 0:
- raise ValueError("'min_verify_time' must be >= 0")
elif key != "schemes":
raise KeyError("unknown CryptContext keyword: %r" % (key,))
return key, value
@@ -1350,7 +1044,7 @@ class _CryptConfig(object):
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.
+ as well as optional *deprecated* keyword.
"""
# get scheme options
kwds, has_cat_options = self.get_scheme_options_with_flag(scheme, category)
@@ -1362,13 +1056,6 @@ class _CryptConfig(object):
if not_inherited:
has_cat_options = True
- # add in min_verify_time setting from context
- value, not_inherited = self.get_context_option_with_flag(category, "min_verify_time")
- if value:
- kwds['min_verify_time'] = value
- if not_inherited:
- has_cat_options = True
-
return kwds, has_cat_options
def get_record(self, scheme, category):
@@ -1443,7 +1130,7 @@ class _CryptConfig(object):
# unique identifiers will work properly in a CryptContext.
# XXX: if all handlers have a unique prefix (e.g. all are MCF / LDAP),
# could use dict-lookup to speed up this search.
- if not isinstance(hash, base_string_types):
+ if not isinstance(hash, unicode_or_bytes_types):
raise ExpectedStringError(hash, "hash")
# type check of category - handled by _get_record_list()
for record in self._get_record_list(category):
@@ -1536,6 +1223,12 @@ class CryptContext(object):
# XXX: would like some way to restrict the categories that are allowed,
# to restrict what the app OR the config can use.
+ # XXX: add wrap/unwrap callback hooks so app can mutate hash format?
+
+ # XXX: add method for detecting and warning user about schemes
+ # which don't have any good distinguishing marks?
+ # or greedy ones (unix_disabled, plaintext) which are not listed at the end?
+
#===================================================================
# instance attrs
#===================================================================
@@ -1595,7 +1288,7 @@ class CryptContext(object):
.. seealso:: :meth:`to_string`, the inverse of this constructor.
"""
- if not isinstance(source, base_string_types):
+ if not isinstance(source, unicode_or_bytes_types):
raise ExpectedTypeError(source, "unicode or bytes", "source")
self = cls(_autoload=False)
self.load(source, section=section, encoding=encoding)
@@ -1759,11 +1452,13 @@ class CryptContext(object):
# and a utf-8 bytes stream under py2,
# allowing the resulting dict to always use native strings.
p = SafeConfigParser()
- if PY_MIN_32:
+ if PY3:
# python 3.2 deprecated readfp in favor of read_file
p.read_file(stream, filename)
else:
p.readfp(stream, filename)
+ # XXX: could change load() to accept list of items,
+ # and skip intermediate dict creation
return dict(p.items(section))
def load_path(self, path, update=False, section="passlib", encoding="utf-8"):
@@ -1855,7 +1550,7 @@ class CryptContext(object):
# autodetect source type, convert to dict
#-----------------------------------------------------------
parse_keys = True
- if isinstance(source, base_string_types):
+ if isinstance(source, unicode_or_bytes_types):
if PY3:
source = to_unicode(source, encoding, param="source")
else:
@@ -1875,7 +1570,7 @@ class CryptContext(object):
#-----------------------------------------------------------
# parse dict keys into (category, scheme, option) format,
- # merge with existing configuration if needed
+ # and merge with existing configuration if needed.
#-----------------------------------------------------------
if parse_keys:
parse = self._parse_config_key
@@ -1903,7 +1598,7 @@ class CryptContext(object):
"""helper used to parse ``cat__scheme__option`` keys into a tuple"""
# split string into 1-3 parts
assert isinstance(ckey, native_string_types)
- parts = ckey.replace(".","__").split("__")
+ parts = ckey.replace(".", "__").split("__")
count = len(parts)
if count == 1:
cat, scheme, key = None, None, parts[0]
@@ -2058,6 +1753,12 @@ class CryptContext(object):
## """
## return self._config.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)
+ ## return kwds
+
def handler(self, scheme=None, category=None):
"""helper to resolve name of scheme -> :class:`~passlib.ifc.PasswordHash` object used by scheme.
@@ -2252,7 +1953,7 @@ class CryptContext(object):
def _get_or_identify_record(self, hash, scheme=None, category=None):
"""return record based on scheme, or failing that, by identifying hash"""
if scheme:
- if not isinstance(hash, base_string_types):
+ if not isinstance(hash, unicode_or_bytes_types):
raise ExpectedStringError(hash, "hash")
return self._get_record(scheme, category)
else:
@@ -2315,7 +2016,7 @@ class CryptContext(object):
.. seealso:: the :ref:`context-migration-example` example in the tutorial.
"""
record = self._get_or_identify_record(hash, scheme, category)
- return record.needs_update(hash, secret)
+ return record.needs_update(hash, secret=secret)
@deprecated_method(deprecated="1.6", removed="2.0", replacement="CryptContext.needs_update()")
def hash_needs_update(self, hash, scheme=None, category=None):
@@ -2451,6 +2152,7 @@ class CryptContext(object):
if record is None:
return None
elif resolve:
+ # XXX: which one should we return? .custom_handler, or .handler?
return record.handler
else:
return record.scheme
@@ -2615,7 +2317,7 @@ class CryptContext(object):
record = self._get_or_identify_record(hash, scheme, category)
if not record.verify(secret, hash, **kwds):
return False, None
- elif record.needs_update(hash, secret):
+ elif record.needs_update(hash, secret=secret):
# NOTE: we re-encrypt with default scheme, not current one.
return True, self.encrypt(secret, None, category, **kwds)
else:
diff --git a/passlib/exc.py b/passlib/exc.py
index b5a1275..c972597 100644
--- a/passlib/exc.py
+++ b/passlib/exc.py
@@ -48,6 +48,21 @@ class PasslibSecurityError(RuntimeError):
.. versionadded:: 1.6.3
"""
+class TokenReuseError(ValueError):
+ """Error raised by various methods in :mod:`passlib.totp` if a token is reused.
+ This exception derives from :exc:`!ValueError`.
+
+ .. versionadded:: 1.7
+ """
+
+ #: optional value indicating when current counter period will end,
+ #: and a new token can be generated.
+ expire_time = None
+
+ def __init__(self, *args, **kwds):
+ self.expire_time = kwds.pop("expire_time", None)
+ ValueError.__init__(self, *args, **kwds)
+
#=============================================================================
# warnings
#=============================================================================
diff --git a/passlib/ext/django/models.py b/passlib/ext/django/models.py
index f82e399..9ef4188 100644
--- a/passlib/ext/django/models.py
+++ b/passlib/ext/django/models.py
@@ -12,8 +12,8 @@ from django.conf import settings
from passlib.context import CryptContext
from passlib.exc import ExpectedTypeError
from passlib.ext.django.utils import _PatchManager, hasher_to_passlib_name, \
- get_passlib_hasher, get_preset_config
-from passlib.utils.compat import callable, unicode, bytes
+ get_passlib_hasher, get_preset_config, MIN_DJANGO_VERSION
+from passlib.utils.compat import unicode
# local
__all__ = ["password_context"]
@@ -50,75 +50,24 @@ def _apply_patch():
assumes the caller will configure the object.
"""
#
- # setup constants
+ # setup environment & patch-paths
#
+ if VERSION < MIN_DJANGO_VERSION:
+ raise RuntimeError("passlib.ext.django requires django >= %s" % (MIN_DJANGO_VERSION,))
+
log.debug("preparing to monkeypatch 'django.contrib.auth' ...")
global _patched
assert not _patched, "monkeypatching already applied"
+
HASHERS_PATH = "django.contrib.auth.hashers"
MODELS_PATH = "django.contrib.auth.models"
USER_PATH = MODELS_PATH + ":User"
FORMS_PATH = "django.contrib.auth.forms"
#
- # import UNUSABLE_PASSWORD and is_password_usable() helpers
- # (providing stubs for older django versions)
+ # import some helpers from hashers module
#
- if VERSION < (1,4):
- has_hashers = False
- if VERSION < (1,0):
- UNUSABLE_PASSWORD = "!"
- else:
- from django.contrib.auth.models import UNUSABLE_PASSWORD
-
- def is_password_usable(encoded):
- return (encoded is not None and encoded != UNUSABLE_PASSWORD)
-
- def is_valid_secret(secret):
- return secret is not None
-
- elif VERSION < (1,6):
- has_hashers = True
- from django.contrib.auth.hashers import UNUSABLE_PASSWORD, \
- is_password_usable
-
- # NOTE: 1.4 - 1.5 - empty passwords no longer valid.
- def is_valid_secret(secret):
- return bool(secret)
-
- else:
- has_hashers = True
- from django.contrib.auth.hashers import is_password_usable
-
- # 1.6 - empty passwords valid again
- def is_valid_secret(secret):
- return secret is not None
-
- if VERSION < (1,6):
- def make_unusable_password():
- return UNUSABLE_PASSWORD
- else:
- from django.contrib.auth.hashers import make_password as _make_password
- def make_unusable_password():
- return _make_password(None)
-
- # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes
- has_unsalted_sha1 = (VERSION >= (1,4,6))
-
- #
- # backport ``User.set_unusable_password()`` for Django 0.9
- # (simplifies rest of the code)
- #
- if not hasattr(_manager.getorig(USER_PATH), "set_unusable_password"):
- assert VERSION < (1,0)
-
- @_manager.monkeypatch(USER_PATH)
- def set_unusable_password(user):
- user.password = make_unusable_password()
-
- @_manager.monkeypatch(USER_PATH)
- def has_usable_password(user):
- return is_password_usable(user.password)
+ from django.contrib.auth.hashers import is_password_usable
#
# patch ``User.set_password() & ``User.check_password()`` to use
@@ -129,20 +78,18 @@ def _apply_patch():
@_manager.monkeypatch(USER_PATH)
def set_password(user, password):
"""passlib replacement for User.set_password()"""
- if is_valid_secret(password):
+ if password is None:
+ user.set_unusable_password()
+ else:
# NOTE: pulls _get_category from module globals
cat = _get_category(user)
user.password = password_context.encrypt(password, category=cat)
- else:
- user.set_unusable_password()
@_manager.monkeypatch(USER_PATH)
def check_password(user, password):
"""passlib replacement for User.check_password()"""
hash = user.password
- if not is_valid_secret(password) or not is_password_usable(hash):
- return False
- if not hash and VERSION < (1,4):
+ if password is None or not is_password_usable(hash):
return False
# NOTE: pulls _get_category from module globals
cat = _get_category(user)
@@ -157,13 +104,13 @@ def _apply_patch():
#
# override check_password() with our own implementation
#
- @_manager.monkeypatch(HASHERS_PATH, enable=has_hashers)
+ @_manager.monkeypatch(HASHERS_PATH)
@_manager.monkeypatch(MODELS_PATH)
def check_password(password, encoded, setter=None, preferred="default"):
"""passlib replacement for check_password()"""
# XXX: this currently ignores "preferred" keyword, since its purpose
# was for hash migration, and that's handled by the context.
- if not is_valid_secret(password) or not is_password_usable(encoded):
+ if password is None or not is_password_usable(encoded):
return False
ok = password_context.verify(password, encoded)
if ok and setter and password_context.needs_update(encoded):
@@ -174,62 +121,60 @@ def _apply_patch():
# patch the other functions defined in the ``hashers`` module, as well
# as any other known locations where they're imported within ``contrib.auth``
#
- if has_hashers:
- @_manager.monkeypatch(HASHERS_PATH)
- @_manager.monkeypatch(MODELS_PATH)
- def make_password(password, salt=None, hasher="default"):
- """passlib replacement for make_password()"""
- if not is_valid_secret(password):
- return make_unusable_password()
- if hasher == "default":
- scheme = None
- else:
- scheme = hasher_to_passlib_name(hasher)
- kwds = dict(scheme=scheme)
- handler = password_context.handler(scheme)
- if "salt" in handler.setting_kwds:
- if hasher.startswith("unsalted_"):
- # Django 1.4.6+ uses a separate 'unsalted_sha1' hasher for "sha1$$digest",
- # but passlib just reuses it's "sha1" handler ("sha1$salt$digest"). To make
- # this work, have to explicitly tell the sha1 handler to use an empty salt.
- kwds['salt'] = ''
- elif salt:
- # Django make_password() autogenerates a salt if salt is bool False (None / ''),
- # so we only pass the keyword on if there's actually a fixed salt.
- kwds['salt'] = salt
- return password_context.encrypt(password, **kwds)
-
- @_manager.monkeypatch(HASHERS_PATH)
- @_manager.monkeypatch(FORMS_PATH)
- def get_hasher(algorithm="default"):
- """passlib replacement for get_hasher()"""
- if algorithm == "default":
- scheme = None
- else:
- scheme = hasher_to_passlib_name(algorithm)
- # NOTE: resolving scheme -> handler instead of
- # passing scheme into get_passlib_hasher(),
- # in case context contains custom handler
- # shadowing name of a builtin handler.
- handler = password_context.handler(scheme)
- return get_passlib_hasher(handler, algorithm=algorithm)
-
- # identify_hasher() was added in django 1.5,
- # patching it anyways for 1.4, so passlib's version is always available.
- @_manager.monkeypatch(HASHERS_PATH)
- @_manager.monkeypatch(FORMS_PATH)
- def identify_hasher(encoded):
- """passlib helper to identify hasher from encoded password"""
- handler = password_context.identify(encoded, resolve=True,
- required=True)
- algorithm = None
- if (has_unsalted_sha1 and handler.name == "django_salted_sha1" and
- encoded.startswith("sha1$$")):
- # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes,
- # but passlib just reuses the "sha1$salt$digest" handler.
- # we want to resolve to correct django hasher.
- algorithm = "unsalted_sha1"
- return get_passlib_hasher(handler, algorithm=algorithm)
+ @_manager.monkeypatch(HASHERS_PATH, wrap=True)
+ @_manager.monkeypatch(MODELS_PATH, wrap=True)
+ def make_password(__wrapped__, password, salt=None, hasher="default"):
+ """passlib replacement for make_password()"""
+ if password is None:
+ return __wrapped__(None)
+ if hasher == "default":
+ scheme = None
+ else:
+ scheme = hasher_to_passlib_name(hasher)
+ kwds = dict(scheme=scheme)
+ handler = password_context.handler(scheme)
+ if "salt" in handler.setting_kwds:
+ if hasher.startswith("unsalted_"):
+ # Django 1.4.6+ uses a separate 'unsalted_sha1' hasher for "sha1$$digest",
+ # but passlib just reuses it's "sha1" handler ("sha1$salt$digest"). To make
+ # this work, have to explicitly tell the sha1 handler to use an empty salt.
+ kwds['salt'] = ''
+ elif salt:
+ # Django make_password() autogenerates a salt if salt is bool False (None / ''),
+ # so we only pass the keyword on if there's actually a fixed salt.
+ kwds['salt'] = salt
+ return password_context.encrypt(password, **kwds)
+
+ @_manager.monkeypatch(HASHERS_PATH)
+ @_manager.monkeypatch(FORMS_PATH)
+ def get_hasher(algorithm="default"):
+ """passlib replacement for get_hasher()"""
+ if algorithm == "default":
+ scheme = None
+ else:
+ scheme = hasher_to_passlib_name(algorithm)
+ # NOTE: resolving scheme -> handler instead of
+ # passing scheme into get_passlib_hasher(),
+ # in case context contains custom handler
+ # shadowing name of a builtin handler.
+ handler = password_context.handler(scheme)
+ return get_passlib_hasher(handler, algorithm=algorithm)
+
+ # identify_hasher() was added in django 1.5,
+ # patching it anyways for 1.4, so passlib's version is always available.
+ @_manager.monkeypatch(HASHERS_PATH)
+ @_manager.monkeypatch(FORMS_PATH)
+ def identify_hasher(encoded):
+ """passlib helper to identify hasher from encoded password"""
+ handler = password_context.identify(encoded, resolve=True,
+ required=True)
+ algorithm = None
+ if handler.name == "django_salted_sha1" and encoded.startswith("sha1$$"):
+ # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes,
+ # but passlib just reuses the "sha1$salt$digest" handler.
+ # we want to resolve to correct django hasher.
+ algorithm = "unsalted_sha1"
+ return get_passlib_hasher(handler, algorithm=algorithm)
_patched = True
log.debug("... finished monkeypatching django")
diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py
index 863f11e..8e70a51 100644
--- a/passlib/ext/django/utils.py
+++ b/passlib/ext/django/utils.py
@@ -3,6 +3,7 @@
# imports
#=============================================================================
# core
+from functools import update_wrapper
import logging; log = logging.getLogger(__name__)
from weakref import WeakKeyDictionary
from warnings import warn
@@ -18,13 +19,18 @@ from passlib.context import CryptContext
from passlib.exc import PasslibRuntimeWarning
from passlib.registry import get_crypt_handler, list_crypt_handlers
from passlib.utils import classproperty
-from passlib.utils.compat import bytes, get_method_function, iteritems
+from passlib.utils.compat import get_method_function, iteritems, OrderedDict
# local
__all__ = [
+ "DJANGO_VERSION",
+ "MIN_DJANGO_VERSION",
"get_preset_config",
"get_passlib_hasher",
]
+#: minimum version supported by passlib.ext.django
+MIN_DJANGO_VERSION = (1, 6)
+
#=============================================================================
# default policies
#=============================================================================
@@ -55,12 +61,7 @@ def get_preset_config(name):
if not DJANGO_VERSION:
raise ValueError("can't resolve django-default preset, "
"django not installed")
- if DJANGO_VERSION < (1,4):
- name = "django-1.0"
- elif DJANGO_VERSION < (1,6):
- name = "django-1.4"
- else:
- name = "django-1.6"
+ name = "django-1.6"
if name == "passlib-default":
return PASSLIB_DEFAULT
try:
@@ -188,7 +189,6 @@ class _HasherWrapper(object):
def safe_summary(self, encoded):
from django.contrib.auth.hashers import mask_hash
from django.utils.translation import ugettext_noop as _
- from django.utils.datastructures import SortedDict
handler = self.passlib_handler
items = [
# since this is user-facing, we're reporting passlib's name,
@@ -200,7 +200,7 @@ class _HasherWrapper(object):
for key, value in iteritems(kwds):
key = self._translate_kwds.get(key, key)
items.append((_(key), value))
- return SortedDict(items)
+ return OrderedDict(items)
# added in django 1.6
def must_update(self, encoded):
@@ -225,12 +225,7 @@ def get_passlib_hasher(handler, algorithm=None):
Note that the format of the handler won't be altered,
so will probably not be compatible with Django's algorithm format,
so the monkeypatch provided by this plugin must have been applied.
-
- .. note::
- This function requires Django 1.4 or later.
"""
- if DJANGO_VERSION < (1,4):
- raise RuntimeError("get_passlib_hasher() requires Django >= 1.4")
if isinstance(handler, str):
handler = get_crypt_handler(handler)
if hasattr(handler, "django_name"):
@@ -440,7 +435,7 @@ class _PatchManager(object):
else:
setattr(obj, attr, value)
- def patch(self, path, value):
+ def patch(self, path, value, wrap=False):
"""monkeypatch object+attr at <path> to have <value>, stores original"""
assert value != _UNSET
current = self._get_path(path)
@@ -454,6 +449,14 @@ class _PatchManager(object):
if not self._is_same_value(current, expected):
warn("overridding resource another library has patched: %r"
% path, PasslibRuntimeWarning)
+ if wrap:
+ assert callable(value)
+ wrapped = orig
+ wrapped_by = value
+ def wrapper(*args, **kwds):
+ return wrapped_by(wrapped, *args, **kwds)
+ update_wrapper(wrapper, value)
+ value = wrapper
self._set_path(path, value)
self._state[path] = (orig, value)
@@ -462,13 +465,13 @@ class _PatchManager(object):
## for path, value in iteritems(kwds):
## self.patch(path, value)
- def monkeypatch(self, parent, name=None, enable=True):
+ def monkeypatch(self, parent, name=None, enable=True, wrap=False):
"""function decorator which patches function of same name in <parent>"""
def builder(func):
if enable:
sep = "." if ":" in parent else ":"
path = parent + sep + (name or func.__name__)
- self.patch(path, func)
+ self.patch(path, func, wrap=wrap)
return func
return builder
diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py
index b0f4de0..23dca0a 100644
--- a/passlib/handlers/bcrypt.py
+++ b/passlib/handlers/bcrypt.py
@@ -19,19 +19,15 @@ import re
import logging; log = logging.getLogger(__name__)
from warnings import warn
# site
-try:
- import bcrypt as _bcrypt
-except ImportError: # pragma: no cover
- _bcrypt = None
-try:
- import bcryptor as _bcryptor
-except ImportError: # pragma: no cover
- _bcryptor = None
+_bcrypt = None # dynamically imported by _load_backend_bcrypt()
+_pybcrypt = None # dynamically imported by _load_backend_pybcrypt()
+_bcryptor = None # dynamically imported by _load_backend_bcryptor()
# pkg
+_builtin_bcrypt = None # dynamically imported by _load_backend_builtin()
from passlib.exc import PasslibHashWarning, PasslibSecurityWarning, PasslibSecurityError
-from passlib.utils import bcrypt64, safe_crypt, repeat_string, to_bytes, \
- classproperty, rng, getrandstr, test_crypt, to_unicode
-from passlib.utils.compat import bytes, b, u, uascii_to_str, unicode, str_to_uascii
+from passlib.utils import bcrypt64, safe_crypt, repeat_string, to_bytes, parse_version, \
+ rng, getrandstr, test_crypt, to_unicode
+from passlib.utils.compat import u, uascii_to_str, unicode, str_to_uascii
import passlib.utils.handlers as uh
# local
@@ -42,19 +38,12 @@ __all__ = [
#=============================================================================
# support funcs & constants
#=============================================================================
-_builtin_bcrypt = None
-
-def _load_builtin():
- global _builtin_bcrypt
- if _builtin_bcrypt is None:
- from passlib.utils._blowfish import raw_bcrypt as _builtin_bcrypt
-
IDENT_2 = u("$2$")
IDENT_2A = u("$2a$")
IDENT_2X = u("$2x$")
IDENT_2Y = u("$2y$")
IDENT_2B = u("$2b$")
-_BNULL = b('\x00')
+_BNULL = b'\x00'
def _detect_pybcrypt():
"""
@@ -211,22 +200,25 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh.
return uascii_to_str(config)
#===================================================================
- # specialized salt generation - fixes passlib issue 25
+ # migration
#===================================================================
@classmethod
- def _bind_needs_update(cls, **settings):
- return cls._needs_update
-
- @classmethod
- def _needs_update(cls, hash, secret):
+ def needs_update(cls, hash, **kwds):
+ # check for incorrect padding bits (passlib issue 25)
if isinstance(hash, bytes):
hash = hash.decode("ascii")
- # check for incorrect padding bits (passlib issue 25)
if hash.startswith(IDENT_2A) and hash[28] not in bcrypt64._padinfo2[1]:
return True
- # TODO: try to detect incorrect $2x$ hashes using *secret*
- return False
+
+ # TODO: try to detect incorrect 8bit/wraparound hashes using kwds.get("secret")
+
+ # hand off to base implementation, so HasRounds can check rounds value.
+ return super(bcrypt, cls).needs_update(hash, **kwds)
+
+ #===================================================================
+ # specialized salt generation - fixes passlib issue 25
+ #===================================================================
@classmethod
def normhash(cls, hash):
@@ -270,16 +262,23 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh.
return checksum
#===================================================================
- # primary interface
+ # backend configuration
#===================================================================
+
backends = ("bcrypt", "pybcrypt", "bcryptor", "os_crypt", "builtin")
+ # appended to HasManyBackends' "no backends available" error message
+ _no_backend_suggestion = " -- recommend you install one (e.g. 'pip install bcrypt')"
+
# backend workaround detection
_has_wraparound_bug = False
_lacks_20_support = False
_lacks_2y_support = False
_lacks_2b_support = False
+ #---------------------------------------------------------------
+ # backend capability/bug detection
+ #---------------------------------------------------------------
@classmethod
def set_backend(cls, *a, **k):
backend = super(bcrypt, cls).set_backend(*a, **k)
@@ -357,38 +356,9 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh.
assert cls._lacks_2b_support or not cls._has_wraparound_bug, \
"sanity check failed: %r backend supports $2b$ but has wraparound bug" % backend
- @classproperty
- def _has_backend_bcrypt(cls):
- return _bcrypt is not None and not _detect_pybcrypt()
-
- @classproperty
- def _has_backend_pybcrypt(cls):
- return _bcrypt is not None and _detect_pybcrypt()
-
- @classproperty
- def _has_backend_bcryptor(cls):
- return _bcryptor is not None
-
- @classproperty
- def _has_backend_builtin(cls):
- if os.environ.get("PASSLIB_BUILTIN_BCRYPT") not in ["enable","enabled"]:
- return False
- # look at it cross-eyed, and it loads itself
- _load_builtin()
- return True
-
- @classproperty
- def _has_backend_os_crypt(cls):
- # XXX: what to do if "2" isn't supported, but "2a" is?
- # "2" is *very* rare, and can fake it using "2a"+repeat_string
- h1 = '$2$04$......................1O4gOrCYaqBG3o/4LnT2ykQUt1wbyju'
- h2 = '$2a$04$......................qiOQjkB8hxU8OzRhS.GhRMa4VUnkPty'
- return test_crypt("test",h1) and test_crypt("test", h2)
-
- @classmethod
- def _no_backends_msg(cls):
- return "no bcrypt backends available -- recommend you install one (e.g. 'pip install bcrypt')"
-
+ #---------------------------------------------------------------
+ # prepare secret & config for backend calc
+ #---------------------------------------------------------------
def _calc_checksum(self, secret):
"""common backend code"""
@@ -450,24 +420,27 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh.
config = self._get_config(ident)
return self._calc_checksum_backend(secret, config)
- def _calc_checksum_os_crypt(self, secret, config):
- hash = safe_crypt(secret, config)
- if hash:
- assert hash.startswith(config) and len(hash) == len(config)+31
- return hash[-31:]
- else:
- # NOTE: Have to raise this error because python3's crypt.crypt() only accepts unicode.
- # This means it can't handle any passwords that aren't either unicode
- # or utf-8 encoded bytes. However, hashing a password with an alternate
- # encoding should be a pretty rare edge case; if user needs it, they can just
- # install bcrypt backend.
- # XXX: is this the right error type to raise?
- # maybe have safe_crypt() not swallow UnicodeDecodeError, and have handlers
- # like sha256_crypt trap it if they have alternate method of handling them?
- raise uh.exc.MissingBackendError(
- "non-utf8 encoded passwords can't be handled by crypt.crypt() under python3, "
- "recommend running `pip install bcrypt`.",
- )
+ #---------------------------------------------------------------
+ # bcrypt backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_bcrypt(cls):
+ # try to import bcrypt
+ global _bcrypt
+ if _detect_pybcrypt():
+ # pybcrypt was installed instead
+ return None
+ try:
+ import bcrypt as _bcrypt
+ except ImportError: # pragma: no cover
+ return None
+ try:
+ version = _bcrypt.__about__.__version__
+ except:
+ log.warning("(trapped) error reading bcrypt version", exc_info=True)
+ version = '<unknown>'
+ log.debug("loaded 'bcrypt' backend, version %r", version)
+ return cls._calc_checksum_bcrypt
def _calc_checksum_bcrypt(self, secret, config):
# bcrypt behavior:
@@ -481,16 +454,76 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh.
assert isinstance(hash, bytes)
return hash[-31:].decode("ascii")
+ #---------------------------------------------------------------
+ # pybcrypt backend
+ #---------------------------------------------------------------
+
+ #: classwide thread lock used for pybcrypt < 0.3
+ _calc_lock = None
+
+ @classmethod
+ def _load_backend_pybcrypt(cls):
+ # try to import pybcrypt
+ global _pybcrypt
+ if not _detect_pybcrypt():
+ # not installed, or bcrypt installed instead
+ return None
+ try:
+ import bcrypt as _pybcrypt
+ except ImportError: # pragma: no cover
+ return None
+
+ # determine pybcrypt version
+ try:
+ version = _pybcrypt._bcrypt.__version__
+ except:
+ log.warning("(trapped) error reading pybcrypt version", exc_info=True)
+ version = "<unknown>"
+ log.debug("loaded 'pybcrypt' backend, version %r", version)
+
+ # return calc function based on version
+ vinfo = parse_version(version) or (0, 0)
+ if vinfo < (0, 3):
+ warn("py-bcrypt %s has a major security vulnerability, "
+ "you should upgrade to py-bcrypt 0.3 immediately."
+ % version, uh.exc.PasslibSecurityWarning)
+ if cls._calc_lock is None:
+ import threading
+ cls._calc_lock = threading.Lock()
+ return cls._calc_checksum_pybcrypt_threadsafe
+ else:
+ return cls._calc_checksum_pybcrypt
+
+ def _calc_checksum_pybcrypt_threadsafe(self, secret, config):
+ # as workaround for pybcrypt < 0.3's concurrency issue,
+ # we wrap everything in a thread lock. as long as bcrypt is only
+ # used through passlib, this should be safe.
+ with self._calc_lock:
+ return self._calc_checksum_pybcrypt(secret, config)
+
def _calc_checksum_pybcrypt(self, secret, config):
# py-bcrypt behavior:
# py2: unicode secret/hash encoded as ascii bytes before use,
# bytes taken as-is; returns ascii bytes.
# py3: unicode secret encoded as utf-8 bytes,
# hash encoded as ascii bytes, returns ascii unicode.
- hash = _bcrypt.hashpw(secret, config)
+ hash = _pybcrypt.hashpw(secret, config)
assert hash.startswith(config) and len(hash) == len(config)+31
return str_to_uascii(hash[-31:])
+ #---------------------------------------------------------------
+ # bcryptor backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_bcryptor(cls):
+ # try to import bcryptor
+ global _bcryptor
+ try:
+ import bcryptor as _bcryptor
+ except ImportError: # pragma: no cover
+ return None
+ return cls._calc_checksum_bcryptor
+
def _calc_checksum_bcryptor(self, secret, config):
# bcryptor behavior:
# py2: unicode secret/hash encoded as ascii bytes before use,
@@ -500,6 +533,50 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh.
assert hash.startswith(config) and len(hash) == len(config)+31
return str_to_uascii(hash[-31:])
+ #---------------------------------------------------------------
+ # os crypt() backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_os_crypt(cls):
+ # XXX: what to do if "2" isn't supported, but "2a" is?
+ # "2" is *very* rare, and can fake it using "2a"+repeat_string
+ h1 = '$2$04$......................1O4gOrCYaqBG3o/4LnT2ykQUt1wbyju'
+ h2 = '$2a$04$......................qiOQjkB8hxU8OzRhS.GhRMa4VUnkPty'
+ if test_crypt("test", h1) and test_crypt("test", h2):
+ return cls._calc_checksum_os_crypt
+ return None
+
+ def _calc_checksum_os_crypt(self, secret, config):
+ hash = safe_crypt(secret, config)
+ if hash:
+ assert hash.startswith(config) and len(hash) == len(config)+31
+ return hash[-31:]
+ else:
+ # NOTE: Have to raise this error because python3's crypt.crypt() only accepts unicode.
+ # This means it can't handle any passwords that aren't either unicode
+ # or utf-8 encoded bytes. However, hashing a password with an alternate
+ # encoding should be a pretty rare edge case; if user needs it, they can just
+ # install bcrypt backend.
+ # XXX: is this the right error type to raise?
+ # maybe have safe_crypt() not swallow UnicodeDecodeError, and have handlers
+ # like sha256_crypt trap it if they have alternate method of handling them?
+ raise uh.exc.MissingBackendError(
+ "non-utf8 encoded passwords can't be handled by crypt.crypt() under python3, "
+ "recommend running `pip install bcrypt`.",
+ )
+
+ #---------------------------------------------------------------
+ # builtin backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_builtin(cls):
+ if os.environ.get("PASSLIB_BUILTIN_BCRYPT") not in ["enable","enabled"]:
+ return None
+ global _builtin_bcrypt
+ if _builtin_bcrypt is None:
+ from passlib.utils._blowfish import raw_bcrypt as _builtin_bcrypt
+ return cls._calc_checksum_builtin
+
def _calc_checksum_builtin(self, secret, config):
chk = _builtin_bcrypt(secret, config[1:config.index("$", 1)],
self.salt.encode("ascii"), self.rounds)
@@ -520,11 +597,17 @@ class bcrypt_sha256(bcrypt):
all the same optional keywords as the base :class:`bcrypt` hash.
.. versionadded:: 1.6.2
+
+ .. versionchanged:: 1.7
+
+ Now defaults to '2b' bcrypt algorithm.
"""
name = "bcrypt_sha256"
- # this is locked at 2a for now.
- ident_values = (IDENT_2A,)
+ # this is locked at 2a/2b for now.
+ ident_values = (IDENT_2A, IDENT_2B)
+
+ default_ident = IDENT_2B
# sample hash:
# $bcrypt-sha256$2a,6$/3OeRpbOf8/l6nPPRdZPp.$nRiyYqPobEZGdNRBWihQhiFDh1ws1tu
@@ -598,10 +681,15 @@ class bcrypt_sha256(bcrypt):
# patch set_backend so it modifies bcrypt class, not this one...
# else the bcrypt.set_backend() tests will call the wrong class.
+ # XXX: move this (and a get_backend wrapper) to bcrypt?
+ # also having to set this in django_bcrypt wrappers
@classmethod
def set_backend(cls, *args, **kwds):
return bcrypt.set_backend(*args, **kwds)
+ # XXX: have _needs_update() mark the $2a$ ones for upgrading?
+ # so do that after we switch to hex encoding?
+
#=============================================================================
# eof
#=============================================================================
diff --git a/passlib/handlers/cisco.py b/passlib/handlers/cisco.py
index 1588e80..adf66a1 100644
--- a/passlib/handlers/cisco.py
+++ b/passlib/handlers/cisco.py
@@ -10,8 +10,8 @@ from warnings import warn
# site
# pkg
from passlib.utils import h64, right_pad_string, to_unicode
-from passlib.utils.compat import b, bascii_to_str, bytes, unicode, u, join_byte_values, \
- join_byte_elems, byte_elem_value, iter_byte_values, uascii_to_str, str_to_uascii
+from passlib.utils.compat import unicode, u, join_byte_values, \
+ join_byte_elems, iter_byte_values, uascii_to_str
import passlib.utils.handlers as uh
# local
__all__ = [
@@ -23,7 +23,7 @@ __all__ = [
# cisco pix firewall hash
#=============================================================================
class cisco_pix(uh.HasUserContext, uh.StaticHandler):
- """This class implements the password hash used by Cisco PIX firewalls,
+ """This class implements the password hash used by (older) Cisco PIX firewalls,
and follows the :ref:`password-hash-api`.
It does a single round of hashing, and relies on the username
as the salt.
@@ -42,6 +42,8 @@ class cisco_pix(uh.HasUserContext, uh.StaticHandler):
Conversely, this *must* be omitted or set to ``""`` in order to correctly
hash passwords which don't have an associated user account
(such as the "enable" password).
+
+ .. versionadded:: 1.6
"""
#===================================================================
# class attrs
@@ -50,26 +52,42 @@ class cisco_pix(uh.HasUserContext, uh.StaticHandler):
checksum_size = 16
checksum_chars = uh.HASH64_CHARS
+ #: control flag signalling cisco_asa mode
+ _is_asa = False
+
#===================================================================
# methods
#===================================================================
def _calc_checksum(self, secret):
+
+ # This function handles both the cisco_pix & cisco_asa formats:
+ # * PIX had a limit of 16 character passwords, and always appended the username.
+ # * ASA 7.0 (2005) increases this limit to 32, and conditionally appends the username.
+ # The two behaviors are controlled based on the _is_asa class-level flag.
+ asa = self._is_asa
+
+ # XXX: No idea what unicode policy is, but all examples are
+ # 7-bit ascii compatible, so using UTF-8.
if isinstance(secret, unicode):
- # XXX: no idea what unicode policy is, but all examples are
- # 7-bit ascii compatible, so using UTF-8
secret = secret.encode("utf-8")
+ seclen = len(secret)
+ # PIX/ASA: Per-user accounts use the first 4 chars of the username as the salt,
+ # whereas global "enable" passwords don't have any salt at all.
+ # ASA only: Don't append user if password is 28 or more characters.
user = self.user
- if user:
- # not positive about this, but it looks like per-user
- # accounts use the first 4 chars of the username as the salt,
- # whereas global "enable" passwords don't have any salt at all.
+ if user and not (asa and seclen > 27):
if isinstance(user, unicode):
user = user.encode("utf-8")
secret += user[:4]
- # null-pad or truncate to 16 bytes
- secret = right_pad_string(secret, 16)
+ # PIX: null-pad or truncate to 16 bytes.
+ # ASA: increase to 32 bytes if password is 13 or more characters.
+ if asa and seclen > 12:
+ padsize = 32
+ else:
+ padsize = 16
+ secret = right_pad_string(secret, padsize)
# md5 digest
hash = md5(secret).digest()
@@ -84,6 +102,23 @@ class cisco_pix(uh.HasUserContext, uh.StaticHandler):
# eoc
#===================================================================
+
+class cisco_asa(cisco_pix):
+ """
+ This class implements the password hash used by Cisco ASA/PIX 7.0 and newer (2005).
+ Aside from a different internal algorithm, it's use and format is identical
+ to the older :class:`cisco_pix` class.
+
+ For passwords less than 13 characters, this should be identical to :class:`!cisco_pix`,
+ but will generate a different hash for anything larger
+ (See the `Format & Algorithm`_ section for the details).
+
+ .. versionadded:: 1.7
+ """
+ name = "cisco_asa"
+ _is_asa = True
+
+
#=============================================================================
# type 7
#=============================================================================
diff --git a/passlib/handlers/des_crypt.py b/passlib/handlers/des_crypt.py
index dc28783..d4e3991 100644
--- a/passlib/handlers/des_crypt.py
+++ b/passlib/handlers/des_crypt.py
@@ -8,8 +8,8 @@ import logging; log = logging.getLogger(__name__)
from warnings import warn
# site
# pkg
-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 import h64, h64big, safe_crypt, test_crypt, to_unicode
+from passlib.utils.compat import byte_elem_value, u, uascii_to_str, unicode
from passlib.utils.des import des_encrypt_int_block
import passlib.utils.handlers as uh
# local
@@ -23,7 +23,7 @@ __all__ = [
#=============================================================================
# pure-python backend for des_crypt family
#=============================================================================
-_BNULL = b('\x00')
+_BNULL = b'\x00'
def _crypt_secret_to_key(secret):
"""convert secret to 64-bit DES key.
@@ -33,7 +33,7 @@ def _crypt_secret_to_key(secret):
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...
+ # 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)
@@ -44,15 +44,12 @@ def _raw_des_crypt(secret, salt):
assert len(salt) == 2
# 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")
+ # but what value they assign these characters varies wildy,
+ # so just rejecting them outright.
+ # 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.
+ salt_value = h64.decode_int12(salt)
# gotta do something - no official policy since this predates unicode
if isinstance(secret, unicode):
@@ -73,12 +70,12 @@ def _raw_des_crypt(secret, salt):
return h64big.encode_int64(result)
def _bsdi_secret_to_key(secret):
- """covert secret to DES key used by bsdi_crypt"""
+ """convert 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
+ 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
@@ -88,10 +85,7 @@ def _raw_bsdi_crypt(secret, rounds, salt):
"""pure-python backend for bsdi_crypt"""
# decode salt
- try:
- salt_value = h64.decode_int24(salt)
- except ValueError: # pragma: no cover - always caught by class
- raise ValueError("invalid salt")
+ salt_value = h64.decode_int24(salt)
# gotta do something - no official policy since this predates unicode
if isinstance(secret, unicode):
@@ -176,25 +170,37 @@ class des_crypt(uh.HasManyBackends, uh.HasSalt, uh.GenericHandler):
#===================================================================
backends = ("os_crypt", "builtin")
- _has_backend_builtin = True
-
- @classproperty
- def _has_backend_os_crypt(cls):
- return test_crypt("test", 'abgOeLfPimXQo')
-
- def _calc_checksum_builtin(self, secret):
- return _raw_des_crypt(secret, self.salt.encode("ascii")).decode("ascii")
+ #---------------------------------------------------------------
+ # os_crypt backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_os_crypt(cls):
+ if test_crypt("test", 'abgOeLfPimXQo'):
+ return cls._calc_checksum_os_crypt
+ return None
def _calc_checksum_os_crypt(self, secret):
- # NOTE: safe_crypt encodes unicode secret -> utf8
- # no official policy since des-crypt predates unicode
+ # NOTE: we let safe_crypt() encode unicode secret -> utf8;
+ # no official policy since des-crypt predates unicode
hash = safe_crypt(secret, self.salt)
if hash:
assert hash.startswith(self.salt) and len(hash) == 13
return hash[2:]
else:
+ # py3's crypt.crypt() can't handle non-utf8 bytes.
+ # fallback to builtin alg, which is always available.
return self._calc_checksum_builtin(secret)
+ #---------------------------------------------------------------
+ # builtin backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_builtin(cls):
+ return cls._calc_checksum_builtin
+
+ def _calc_checksum_builtin(self, secret):
+ return _raw_des_crypt(secret, self.salt.encode("ascii")).decode("ascii")
+
#===================================================================
# eoc
#===================================================================
@@ -286,7 +292,8 @@ class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler
# validation
#===================================================================
- # flag so CryptContext won't generate even rounds.
+ # NOTE: keeping this flag for admin/choose_rounds.py script.
+ # want to eventually expose rounds logic to that script in better way.
_avoid_even_rounds = True
def _norm_rounds(self, rounds):
@@ -298,32 +305,39 @@ class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler
uh.exc.PasslibSecurityWarning)
return rounds
- @classmethod
- def _bind_needs_update(cls, **settings):
- return cls._needs_update
+ def _generate_rounds(self):
+ rounds = super(bsdi_crypt, self)._generate_rounds()
+ # ensure autogenerated rounds are always odd
+ # NOTE: doing this even for default_rounds so needs_update() doesn't get
+ # caught in a loop.
+ # FIXME: this technically might generate a rounds value 1 larger
+ # than the requested upper bound - but better to err on side of safety.
+ return rounds|1
- @classmethod
- def _needs_update(cls, hash, secret):
+ #===================================================================
+ # migration
+ #===================================================================
+
+ def _calc_needs_update(self, **kwds):
# 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
+ if not self.rounds & 1:
+ return True
+ # hand off to base implementation
+ return super(bsdi_crypt, self)._calc_needs_update(**kwds)
#===================================================================
# backends
#===================================================================
backends = ("os_crypt", "builtin")
- _has_backend_builtin = True
-
- @classproperty
- def _has_backend_os_crypt(cls):
- return test_crypt("test", '_/...lLDAxARksGCHin.')
-
- def _calc_checksum_builtin(self, secret):
- return _raw_bsdi_crypt(secret, self.rounds, self.salt.encode("ascii")).decode("ascii")
+ #---------------------------------------------------------------
+ # os_crypt backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_os_crypt(cls):
+ if test_crypt("test", '_/...lLDAxARksGCHin.'):
+ return cls._calc_checksum_os_crypt
+ return None
def _calc_checksum_os_crypt(self, secret):
config = self.to_string()
@@ -332,8 +346,20 @@ class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler
assert hash.startswith(config[:9]) and len(hash) == 20
return hash[-11:]
else:
+ # py3's crypt.crypt() can't handle non-utf8 bytes.
+ # fallback to builtin alg, which is always available.
return self._calc_checksum_builtin(secret)
+ #---------------------------------------------------------------
+ # builtin backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_builtin(cls):
+ return cls._calc_checksum_builtin
+
+ def _calc_checksum_builtin(self, secret):
+ return _raw_bsdi_crypt(secret, self.rounds, self.salt.encode("ascii")).decode("ascii")
+
#===================================================================
# eoc
#===================================================================
diff --git a/passlib/handlers/digests.py b/passlib/handlers/digests.py
index 402c670..39ef4b4 100644
--- a/passlib/handlers/digests.py
+++ b/passlib/handlers/digests.py
@@ -6,11 +6,10 @@
# core
import hashlib
import logging; log = logging.getLogger(__name__)
-from warnings import warn
# site
# pkg
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
+from passlib.utils.compat import unicode, str_to_uascii
import passlib.utils.handlers as uh
from passlib.utils.md4 import md4
# local
diff --git a/passlib/handlers/django.py b/passlib/handlers/django.py
index 59574a1..d6972a7 100644
--- a/passlib/handlers/django.py
+++ b/passlib/handlers/django.py
@@ -6,14 +6,12 @@
from base64 import b64encode
from binascii import hexlify
from hashlib import md5, sha1, sha256
-import re
import logging; log = logging.getLogger(__name__)
-from warnings import warn
# site
# pkg
from passlib.hash import bcrypt, pbkdf2_sha1, pbkdf2_sha256
from passlib.utils import to_unicode, classproperty
-from passlib.utils.compat import b, bytes, str_to_uascii, uascii_to_str, unicode, u
+from passlib.utils.compat import str_to_uascii, uascii_to_str, unicode, u
from passlib.utils.pbkdf2 import pbkdf2
import passlib.utils.handlers as uh
# local
@@ -200,9 +198,6 @@ class django_bcrypt_sha256(bcrypt):
django_name = "bcrypt_sha256"
_digest = sha256
- # NOTE: django bcrypt ident locked at "$2a$", so omitting 'ident' support.
- setting_kwds = ("salt", "rounds")
-
# sample hash:
# bcrypt_sha256$$2a$06$/3OeRpbOf8/l6nPPRdZPp.nRiyYqPobEZGdNRBWihQhiFDh1ws1tu
@@ -227,12 +222,6 @@ class django_bcrypt_sha256(bcrypt):
raise uh.exc.MalformedHashError(cls)
return super(django_bcrypt_sha256, cls).from_string(bhash)
- def __init__(self, **kwds):
- if 'ident' in kwds and kwds.get("use_defaults"):
- raise TypeError("%s does not support the ident keyword" %
- self.__class__.__name__)
- return super(django_bcrypt_sha256, self).__init__(**kwds)
-
def to_string(self):
bhash = super(django_bcrypt_sha256, self).to_string()
return uascii_to_str(self.django_prefix) + bhash
diff --git a/passlib/handlers/fshp.py b/passlib/handlers/fshp.py
index 920283f..5d47518 100644
--- a/passlib/handlers/fshp.py
+++ b/passlib/handlers/fshp.py
@@ -8,12 +8,11 @@
from base64 import b64encode, b64decode
import re
import logging; log = logging.getLogger(__name__)
-from warnings import warn
# site
# pkg
from passlib.utils import to_unicode
import passlib.utils.handlers as uh
-from passlib.utils.compat import b, bytes, bascii_to_str, iteritems, u,\
+from passlib.utils.compat import bascii_to_str, iteritems, u,\
unicode
from passlib.utils.pbkdf2 import pbkdf1
# local
@@ -171,7 +170,7 @@ class fshp(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
@property
def _stub_checksum(self):
- return b('\x00') * self.checksum_size
+ return b'\x00' * self.checksum_size
def to_string(self):
chk = self.checksum or self._stub_checksum
diff --git a/passlib/handlers/ldap_digests.py b/passlib/handlers/ldap_digests.py
index fb378c0..e92c658 100644
--- a/passlib/handlers/ldap_digests.py
+++ b/passlib/handlers/ldap_digests.py
@@ -8,13 +8,11 @@ from base64 import b64encode, b64decode
from hashlib import md5, sha1
import logging; log = logging.getLogger(__name__)
import re
-from warnings import warn
# site
# pkg
from passlib.handlers.misc import plaintext
-from passlib.utils import to_native_str, unix_crypt_schemes, \
- classproperty, to_unicode
-from passlib.utils.compat import b, bytes, uascii_to_str, unicode, u
+from passlib.utils import unix_crypt_schemes, classproperty, to_unicode
+from passlib.utils.compat import uascii_to_str, unicode, u
import passlib.utils.handlers as uh
# local
__all__ = [
@@ -161,7 +159,7 @@ class ldap_salted_md5(_SaltedBase64DigestHelper):
checksum_size = 16
_hash_func = md5
_hash_regex = re.compile(u(r"^\{SMD5\}(?P<tmp>[+/a-zA-Z0-9]{27,}={0,2})$"))
- _stub_checksum = b('\x00') * 16
+ _stub_checksum = b'\x00' * 16
class ldap_salted_sha1(_SaltedBase64DigestHelper):
"""This class stores passwords using LDAP's salted SHA1 format, and follows the :ref:`password-hash-api`.
@@ -201,7 +199,7 @@ class ldap_salted_sha1(_SaltedBase64DigestHelper):
checksum_size = 20
_hash_func = sha1
_hash_regex = re.compile(u(r"^\{SSHA\}(?P<tmp>[+/a-zA-Z0-9]{32,}={0,2})$"))
- _stub_checksum = b('\x00') * 20
+ _stub_checksum = b'\x00' * 20
class ldap_plaintext(plaintext):
"""This class stores passwords in plaintext, and follows the :ref:`password-hash-api`.
diff --git a/passlib/handlers/md5_crypt.py b/passlib/handlers/md5_crypt.py
index bb28b16..3d3cb8a 100644
--- a/passlib/handlers/md5_crypt.py
+++ b/passlib/handlers/md5_crypt.py
@@ -4,13 +4,11 @@
#=============================================================================
# core
from hashlib import md5
-import re
import logging; log = logging.getLogger(__name__)
-from warnings import warn
# site
# pkg
-from passlib.utils import classproperty, h64, safe_crypt, test_crypt, repeat_string
-from passlib.utils.compat import b, bytes, irange, unicode, u
+from passlib.utils import h64, safe_crypt, test_crypt, repeat_string
+from passlib.utils.compat import unicode, u
import passlib.utils.handlers as uh
# local
__all__ = [
@@ -21,9 +19,9 @@ __all__ = [
#=============================================================================
# pure-python backend
#=============================================================================
-_BNULL = b("\x00")
-_MD5_MAGIC = b("$1$")
-_APR_MAGIC = b("$apr1$")
+_BNULL = b"\x00"
+_MD5_MAGIC = b"$1$"
+_APR_MAGIC = b"$apr1$"
# pre-calculated offsets used to speed up C digest stage (see notes below).
# sequence generated using the following:
@@ -267,14 +265,14 @@ class md5_crypt(uh.HasManyBackends, _MD5_Common):
backends = ("os_crypt", "builtin")
- _has_backend_builtin = True
-
- @classproperty
- def _has_backend_os_crypt(cls):
- return test_crypt("test", '$1$test$pi/xDtU5WFVRqYS6BMU8X/')
-
- def _calc_checksum_builtin(self, secret):
- return _raw_md5_crypt(secret, self.salt)
+ #---------------------------------------------------------------
+ # os_crypt backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_os_crypt(cls):
+ if test_crypt("test", '$1$test$pi/xDtU5WFVRqYS6BMU8X/'):
+ return cls._calc_checksum_os_crypt
+ return None
def _calc_checksum_os_crypt(self, secret):
config = self.ident + self.salt
@@ -283,8 +281,20 @@ class md5_crypt(uh.HasManyBackends, _MD5_Common):
assert hash.startswith(config) and len(hash) == len(config) + 23
return hash[-22:]
else:
+ # py3's crypt.crypt() can't handle non-utf8 bytes.
+ # fallback to builtin alg, which is always available.
return self._calc_checksum_builtin(secret)
+ #---------------------------------------------------------------
+ # builtin backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_builtin(cls):
+ return cls._calc_checksum_builtin
+
+ def _calc_checksum_builtin(self, secret):
+ return _raw_md5_crypt(secret, self.salt)
+
#===================================================================
# eoc
#===================================================================
diff --git a/passlib/handlers/misc.py b/passlib/handlers/misc.py
index a89ac72..71723bf 100644
--- a/passlib/handlers/misc.py
+++ b/passlib/handlers/misc.py
@@ -10,7 +10,7 @@ from warnings import warn
# site
# pkg
from passlib.utils import to_native_str, consteq
-from passlib.utils.compat import bytes, unicode, u, b, base_string_types
+from passlib.utils.compat import unicode, u, unicode_or_bytes_types
import passlib.utils.handlers as uh
# local
__all__ = [
@@ -45,7 +45,7 @@ class unix_fallback(uh.StaticHandler):
@classmethod
def identify(cls, hash):
- if isinstance(hash, base_string_types):
+ if isinstance(hash, unicode_or_bytes_types):
return True
else:
raise uh.exc.ExpectedStringError(hash, "hash")
@@ -80,7 +80,7 @@ class unix_fallback(uh.StaticHandler):
@classmethod
def verify(cls, secret, hash, enable_wildcard=False):
uh.validate_secret(secret)
- if not isinstance(hash, base_string_types):
+ if not isinstance(hash, unicode_or_bytes_types):
raise uh.exc.ExpectedStringError(hash, "hash")
elif hash:
return False
@@ -88,7 +88,7 @@ class unix_fallback(uh.StaticHandler):
return enable_wildcard
_MARKER_CHARS = u("*!")
-_MARKER_BYTES = b("*!")
+_MARKER_BYTES = b"*!"
class unix_disabled(uh.PasswordHash):
"""This class provides disabled password behavior for unix shadow files,
@@ -206,7 +206,7 @@ class plaintext(uh.PasswordHash):
@classmethod
def identify(cls, hash):
- if isinstance(hash, base_string_types):
+ if isinstance(hash, unicode_or_bytes_types):
return True
else:
raise uh.exc.ExpectedStringError(hash, "hash")
diff --git a/passlib/handlers/mssql.py b/passlib/handlers/mssql.py
index d50100f..21d7133 100644
--- a/passlib/handlers/mssql.py
+++ b/passlib/handlers/mssql.py
@@ -43,7 +43,7 @@ from warnings import warn
# site
# pkg
from passlib.utils import consteq
-from passlib.utils.compat import b, bytes, bascii_to_str, unicode, u
+from passlib.utils.compat import bascii_to_str, unicode, u
import passlib.utils.handlers as uh
# local
__all__ = [
@@ -59,7 +59,7 @@ def _raw_mssql(secret, salt):
assert isinstance(salt, bytes)
return sha1(secret.encode("utf-16-le") + salt).digest()
-BIDENT = b("0x0100")
+BIDENT = b"0x0100"
##BIDENT2 = b("\x01\x00")
UIDENT = u("0x0100")
@@ -127,7 +127,7 @@ class mssql2000(uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
setting_kwds = ("salt",)
checksum_size = 40
min_salt_size = max_salt_size = 4
- _stub_checksum = b("\x00") * 40
+ _stub_checksum = b"\x00" * 40
#===================================================================
# formatting
@@ -206,7 +206,7 @@ class mssql2005(uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
checksum_size = 20
min_salt_size = max_salt_size = 4
- _stub_checksum = b("\x00") * 20
+ _stub_checksum = b"\x00" * 20
#===================================================================
# formatting
diff --git a/passlib/handlers/mysql.py b/passlib/handlers/mysql.py
index 8f57d05..7e31313 100644
--- a/passlib/handlers/mysql.py
+++ b/passlib/handlers/mysql.py
@@ -30,7 +30,7 @@ from warnings import warn
# site
# pkg
from passlib.utils import to_native_str
-from passlib.utils.compat import b, bascii_to_str, bytes, unicode, u, \
+from passlib.utils.compat import bascii_to_str, unicode, u, \
byte_elem_value, str_to_uascii
import passlib.utils.handlers as uh
# local
@@ -70,7 +70,7 @@ class mysql323(uh.StaticHandler):
MASK_32 = 0xffffffff
MASK_31 = 0x7fffffff
- WHITE = b(' \t')
+ WHITE = b' \t'
nr1 = 0x50305735
nr2 = 0x12345671
diff --git a/passlib/handlers/oracle.py b/passlib/handlers/oracle.py
index 3cd3ba1..252c159 100644
--- a/passlib/handlers/oracle.py
+++ b/passlib/handlers/oracle.py
@@ -7,11 +7,10 @@ from binascii import hexlify, unhexlify
from hashlib import sha1
import re
import logging; log = logging.getLogger(__name__)
-from warnings import warn
# site
# pkg
-from passlib.utils import to_unicode, to_native_str, xor_bytes
-from passlib.utils.compat import b, bytes, bascii_to_str, irange, u, \
+from passlib.utils import to_unicode, xor_bytes
+from passlib.utils.compat import irange, u, \
uascii_to_str, unicode, str_to_uascii
from passlib.utils.des import des_encrypt_block
import passlib.utils.handlers as uh
@@ -24,7 +23,7 @@ __all__ = [
#=============================================================================
# oracle10
#=============================================================================
-def des_cbc_encrypt(key, value, iv=b('\x00') * 8, pad=b('\x00')):
+def des_cbc_encrypt(key, value, iv=b'\x00' * 8, pad=b'\x00'):
"""performs des-cbc encryption, returns only last block.
this performs a specific DES-CBC encryption implementation
@@ -48,7 +47,7 @@ def des_cbc_encrypt(key, value, iv=b('\x00') * 8, pad=b('\x00')):
return hash
# magic string used as initial des key by oracle10
-ORACLE10_MAGIC = b("\x01\x23\x45\x67\x89\xAB\xCD\xEF")
+ORACLE10_MAGIC = b"\x01\x23\x45\x67\x89\xAB\xCD\xEF"
class oracle10(uh.HasUserContext, uh.StaticHandler):
"""This class implements the password hash used by Oracle up to version 10g, and follows the :ref:`password-hash-api`.
diff --git a/passlib/handlers/pbkdf2.py b/passlib/handlers/pbkdf2.py
index fd5fbad..c14c24b 100644
--- a/passlib/handlers/pbkdf2.py
+++ b/passlib/handlers/pbkdf2.py
@@ -5,13 +5,11 @@
# core
from binascii import hexlify, unhexlify
from base64 import b64encode, b64decode
-import re
import logging; log = logging.getLogger(__name__)
-from warnings import warn
# site
# pkg
from passlib.utils import ab64_decode, ab64_encode, to_unicode
-from passlib.utils.compat import b, bytes, str_to_bascii, u, uascii_to_str, unicode
+from passlib.utils.compat import str_to_bascii, u, uascii_to_str, unicode
from passlib.utils.pbkdf2 import pbkdf2
import passlib.utils.handlers as uh
# local
@@ -148,7 +146,7 @@ ldap_pbkdf2_sha512 = uh.PrefixWrapper("ldap_pbkdf2_sha512", pbkdf2_sha512, "{PBK
#=============================================================================
# bytes used by cta hash for base64 values 63 & 64
-CTA_ALTCHARS = b("-_")
+CTA_ALTCHARS = b"-_"
class cta_pbkdf2_sha1(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
"""This class implements Cryptacular's PBKDF2-based crypt algorithm, and follows the :ref:`password-hash-api`.
@@ -381,7 +379,7 @@ class atlassian_pbkdf2_sha1(uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler)
ident = u("{PKCS5S2}")
checksum_size = 32
- _stub_checksum = b("\x00") * 32
+ _stub_checksum = b"\x00" * 32
#--HasRawSalt--
min_salt_size = max_salt_size = 16
diff --git a/passlib/handlers/phpass.py b/passlib/handlers/phpass.py
index 7db32b0..3dc955d 100644
--- a/passlib/handlers/phpass.py
+++ b/passlib/handlers/phpass.py
@@ -10,13 +10,11 @@ phpass context - blowfish, bsdi_crypt, phpass
#=============================================================================
# core
from hashlib import md5
-import re
import logging; log = logging.getLogger(__name__)
-from warnings import warn
# site
# pkg
from passlib.utils import h64
-from passlib.utils.compat import b, bytes, u, uascii_to_str, unicode
+from passlib.utils.compat import u, uascii_to_str, unicode
import passlib.utils.handlers as uh
# local
__all__ = [
diff --git a/passlib/handlers/postgres.py b/passlib/handlers/postgres.py
index b8683dd..634b77e 100644
--- a/passlib/handlers/postgres.py
+++ b/passlib/handlers/postgres.py
@@ -4,13 +4,11 @@
#=============================================================================
# core
from hashlib import md5
-import re
import logging; log = logging.getLogger(__name__)
-from warnings import warn
# site
# pkg
from passlib.utils import to_bytes
-from passlib.utils.compat import b, bytes, str_to_uascii, unicode, u
+from passlib.utils.compat import str_to_uascii, unicode, u
import passlib.utils.handlers as uh
# local
__all__ = [
diff --git a/passlib/handlers/scram.py b/passlib/handlers/scram.py
index 02133cd..a4ba68a 100644
--- a/passlib/handlers/scram.py
+++ b/passlib/handlers/scram.py
@@ -3,20 +3,13 @@
# imports
#=============================================================================
# core
-from binascii import hexlify, unhexlify
-from base64 import b64encode, b64decode
-import hashlib
-import re
import logging; log = logging.getLogger(__name__)
-from warnings import warn
# site
# pkg
-from passlib.exc import PasslibHashWarning
from passlib.utils import ab64_decode, ab64_encode, consteq, saslprep, \
- to_native_str, xor_bytes, splitcomma
-from passlib.utils.compat import b, bytes, bascii_to_str, iteritems, \
- PY3, u, unicode, native_string_types
-from passlib.utils.pbkdf2 import pbkdf2, get_prf, norm_hash_name
+ to_native_str, splitcomma
+from passlib.utils.compat import bascii_to_str, iteritems, u, native_string_types
+from passlib.utils.pbkdf2 import pbkdf2, norm_hash_name
import passlib.utils.handlers as uh
# local
__all__ = [
@@ -292,6 +285,27 @@ class scram(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
return '$scram$%d$%s$%s' % (self.rounds, salt, chk_str)
#===================================================================
+ # variant constructor
+ #===================================================================
+ @classmethod
+ def using(cls, default_algs=None, algs=None, **kwds):
+ # parse aliases
+ if algs is not None:
+ assert default_algs is None
+ default_algs = algs
+
+ # create subclass
+ subcls = super(scram, cls).using(**kwds)
+
+ # fill in algs
+ if default_algs is not None:
+ # hack so we can use _norm_algs even though it's an instance method.
+ # XXX: use_defaults is only thing keeping it from being a classmethod.
+ subcls.default_algs = cls(use_defaults=True)._norm_algs(default_algs)
+
+ return subcls
+
+ #===================================================================
# init
#===================================================================
def __init__(self, algs=None, **kwds):
@@ -343,19 +357,21 @@ class scram(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
return algs
#===================================================================
- # digest methods
+ # migration
#===================================================================
+ def _calc_needs_update(self, **kwds):
+ # marks hashes as deprecated if they don't include at least all default_algs.
+ # XXX: should we deprecate if they aren't exactly the same,
+ # to permit removing legacy hashes?
+ if not set(self.algs).issuperset(self.default_algs):
+ return True
- @classmethod
- def _bind_needs_update(cls, **settings):
- """generate a deprecation detector for CryptContext to use"""
- # generate deprecation hook which marks hashes as deprecated
- # if they don't support a superset of current algs.
- algs = frozenset(cls(use_defaults=True, **settings).algs)
- def detector(hash, secret):
- return not algs.issubset(cls.from_string(hash).algs)
- return detector
+ # hand off to base implementation
+ return super(scram, self)._calc_needs_update(**kwds)
+ #===================================================================
+ # digest methods
+ #===================================================================
def _calc_checksum(self, secret, alg=None):
rounds = self.rounds
salt = self.salt
diff --git a/passlib/handlers/sha1_crypt.py b/passlib/handlers/sha1_crypt.py
index b243bc0..0717d66 100644
--- a/passlib/handlers/sha1_crypt.py
+++ b/passlib/handlers/sha1_crypt.py
@@ -6,16 +6,12 @@
#=============================================================================
# core
-from hmac import new as hmac
-from hashlib import sha1
-import re
import logging; log = logging.getLogger(__name__)
-from warnings import warn
# site
# pkg
-from passlib.utils import classproperty, h64, safe_crypt, test_crypt
-from passlib.utils.compat import b, bytes, u, uascii_to_str, unicode
-from passlib.utils.pbkdf2 import get_prf
+from passlib.utils import h64, safe_crypt, test_crypt
+from passlib.utils.compat import u, unicode, irange
+from passlib.utils.pbkdf2 import get_keyed_prf
import passlib.utils.handlers as uh
# local
__all__ = [
@@ -23,8 +19,7 @@ __all__ = [
#=============================================================================
# sha1-crypt
#=============================================================================
-_hmac_sha1 = get_prf("hmac-sha1")[0]
-_BNULL = b('\x00')
+_BNULL = b'\x00'
class sha1_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler):
"""This class implements the SHA1-Crypt password hash, and follows the :ref:`password-hash-api`.
@@ -85,7 +80,6 @@ class sha1_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler
#===================================================================
# formatting
#===================================================================
-
@classmethod
def from_string(cls, hash):
rounds, salt, chk = uh.parse_mc3(hash, cls.ident, handler=cls)
@@ -100,12 +94,33 @@ class sha1_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler
#===================================================================
backends = ("os_crypt", "builtin")
- _has_backend_builtin = True
+ #---------------------------------------------------------------
+ # os_crypt backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_os_crypt(cls):
+ if test_crypt("test", '$sha1$1$Wq3GL2Vp$C8U25GvfHS8qGHim'
+ 'ExLaiSFlGkAe'):
+ return cls._calc_checksum_os_crypt
+ return None
- @classproperty
- def _has_backend_os_crypt(cls):
- return test_crypt("test", '$sha1$1$Wq3GL2Vp$C8U25GvfHS8qGHim'
- 'ExLaiSFlGkAe')
+ def _calc_checksum_os_crypt(self, secret):
+ config = self.to_string(config=True)
+ hash = safe_crypt(secret, config)
+ if hash:
+ assert hash.startswith(config) and len(hash) == len(config) + 29
+ return hash[-28:]
+ else:
+ # py3's crypt.crypt() can't handle non-utf8 bytes.
+ # fallback to builtin alg, which is always available.
+ return self._calc_checksum_builtin(secret)
+
+ #---------------------------------------------------------------
+ # builtin backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_builtin(cls):
+ return cls._calc_checksum_builtin
def _calc_checksum_builtin(self, secret):
if isinstance(secret, unicode):
@@ -116,10 +131,9 @@ class sha1_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler
# NOTE: this seed value is NOT the same as the config string
result = (u("%s$sha1$%s") % (self.salt, rounds)).encode("ascii")
# NOTE: this algorithm is essentially PBKDF1, modified to use HMAC.
- r = 0
- while r < rounds:
- result = _hmac_sha1(secret, result)
- r += 1
+ keyed_hmac = get_keyed_prf("hmac-sha1", secret)[0]
+ for _ in irange(rounds):
+ result = keyed_hmac(result)
return h64.encode_transposed_bytes(result, self._chk_offsets).decode("ascii")
_chk_offsets = [
@@ -132,15 +146,6 @@ class sha1_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler
0,19,18,
]
- def _calc_checksum_os_crypt(self, secret):
- config = self.to_string(config=True)
- hash = safe_crypt(secret, config)
- if hash:
- assert hash.startswith(config) and len(hash) == len(config) + 29
- return hash[-28:]
- else:
- return self._calc_checksum_builtin(secret)
-
#===================================================================
# eoc
#===================================================================
diff --git a/passlib/handlers/sha2_crypt.py b/passlib/handlers/sha2_crypt.py
index 3c3dd66..88bc2a7 100644
--- a/passlib/handlers/sha2_crypt.py
+++ b/passlib/handlers/sha2_crypt.py
@@ -5,12 +5,11 @@
# core
import hashlib
import logging; log = logging.getLogger(__name__)
-from warnings import warn
# site
# pkg
-from passlib.utils import classproperty, h64, safe_crypt, test_crypt, \
+from passlib.utils import h64, safe_crypt, test_crypt, \
repeat_string, to_unicode
-from passlib.utils.compat import b, bytes, byte_elem_value, irange, u, \
+from passlib.utils.compat import byte_elem_value, u, \
uascii_to_str, unicode
import passlib.utils.handlers as uh
# local
@@ -23,7 +22,7 @@ __all__ = [
# pure-python backend, used by both sha256_crypt & sha512_crypt
# when crypt.crypt() backend is not available.
#=============================================================================
-_BNULL = b('\x00')
+_BNULL = b'\x00'
# pre-calculated offsets used to speed up C digest stage (see notes below).
# sequence generated using the following:
@@ -72,6 +71,19 @@ def _raw_sha2_crypt(pwd, salt, rounds, use_512=False):
# init & validate inputs
#===================================================================
+ # NOTE: the setup portion of this algorithm scales ~linearly in time
+ # with the size of the password, making it vulnerable to a DOS from
+ # unreasonably large inputs. the following code has some optimizations
+ # which would make things even worse, using O(pwd_len**2) memory
+ # when calculating digest P.
+ #
+ # to mitigate these two issues: 1) this code switches to a
+ # O(pwd_len)-memory algorithm for passwords that are much larger
+ # than average, and 2) Passlib enforces a library-wide max limit on
+ # the size of passwords it will allow, to prevent this algorithm and
+ # others from being DOSed in this way (see passlib.exc.PasswordSizeError
+ # for details).
+
# validate secret
if isinstance(pwd, unicode):
# XXX: not sure what official unicode policy is, using this as default
@@ -134,11 +146,12 @@ def _raw_sha2_crypt(pwd, salt, rounds, use_512=False):
# digest P from password - used instead of password itself
# when calculating digest C.
#===================================================================
- if pwd_len < 64:
- # method this is faster under python, but uses O(pwd_len**2) memory
- # so we don't use it for larger passwords, to avoid a potential DOS.
+ if pwd_len < 96:
+ # this method is faster under python, but uses O(pwd_len**2) memory;
+ # so we don't use it for larger passwords to avoid a potential DOS.
dp = repeat_string(hash_const(pwd * pwd_len).digest(), pwd_len)
else:
+ # this method is slower under python, but uses a fixed amount of memory.
tmp_ctx = hash_const(pwd)
tmp_ctx_update = tmp_ctx.update
i = pwd_len-1
@@ -335,13 +348,18 @@ class _SHA2_Common(uh.HasManyBackends, uh.HasRounds, uh.HasSalt,
#===================================================================
backends = ("os_crypt", "builtin")
- _has_backend_builtin = True
+ #---------------------------------------------------------------
+ # os_crypt backend
+ #---------------------------------------------------------------
- # _has_backend_os_crypt - provided by subclass
+ #: test hash for OS detection -- provided by subclass
+ _test_hash = None
- def _calc_checksum_builtin(self, secret):
- return _raw_sha2_crypt(secret, self.salt, self.rounds,
- self._cdb_use_512)
+ @classmethod
+ def _load_backend_os_crypt(cls):
+ if test_crypt(*cls._test_hash):
+ return cls._calc_checksum_os_crypt
+ return None
def _calc_checksum_os_crypt(self, secret):
hash = safe_crypt(secret, self.to_string())
@@ -352,8 +370,21 @@ class _SHA2_Common(uh.HasManyBackends, uh.HasRounds, uh.HasSalt,
assert hash.startswith(self.ident) and hash[-cs-1] == _UDOLLAR
return hash[-cs:]
else:
+ # py3's crypt.crypt() can't handle non-utf8 bytes.
+ # fallback to builtin alg, which is always available.
return self._calc_checksum_builtin(secret)
+ #---------------------------------------------------------------
+ # builtin backend
+ #---------------------------------------------------------------
+ @classmethod
+ def _load_backend_builtin(cls):
+ return cls._calc_checksum_builtin
+
+ def _calc_checksum_builtin(self, secret):
+ return _raw_sha2_crypt(secret, self.salt, self.rounds,
+ self._cdb_use_512)
+
#===================================================================
# eoc
#===================================================================
@@ -407,10 +438,8 @@ class sha256_crypt(_SHA2_Common):
#===================================================================
# backends
#===================================================================
- @classproperty
- def _has_backend_os_crypt(cls):
- return test_crypt("test", "$5$rounds=1000$test$QmQADEXMG8POI5W"
- "Dsaeho0P36yK3Tcrgboabng6bkb/")
+ _test_hash = ("test", "$5$rounds=1000$test$QmQADEXMG8POI5W"
+ "Dsaeho0P36yK3Tcrgboabng6bkb/")
#===================================================================
# eoc
@@ -470,12 +499,10 @@ class sha512_crypt(_SHA2_Common):
#===================================================================
# backend
#===================================================================
- @classproperty
- def _has_backend_os_crypt(cls):
- return test_crypt("test", "$6$rounds=1000$test$2M/Lx6Mtobqj"
- "Ljobw0Wmo4Q5OFx5nVLJvmgseatA6oMn"
- "yWeBdRDx4DU.1H3eGmse6pgsOgDisWBG"
- "I5c7TZauS0")
+ _test_hash = ("test", "$6$rounds=1000$test$2M/Lx6Mtobqj"
+ "Ljobw0Wmo4Q5OFx5nVLJvmgseatA6oMn"
+ "yWeBdRDx4DU.1H3eGmse6pgsOgDisWBG"
+ "I5c7TZauS0")
#===================================================================
# eoc
diff --git a/passlib/handlers/sun_md5_crypt.py b/passlib/handlers/sun_md5_crypt.py
index a6a966d..499f1f8 100644
--- a/passlib/handlers/sun_md5_crypt.py
+++ b/passlib/handlers/sun_md5_crypt.py
@@ -18,7 +18,7 @@ from warnings import warn
# site
# pkg
from passlib.utils import h64, to_unicode
-from passlib.utils.compat import b, bytes, byte_elem_value, irange, u, \
+from passlib.utils.compat import byte_elem_value, irange, u, \
uascii_to_str, unicode, str_to_bascii
import passlib.utils.handlers as uh
# local
@@ -33,42 +33,42 @@ __all__ = [
# exact bytes as in http://www.ibiblio.org/pub/docs/books/gutenberg/etext98/2ws2610.txt
# from Project Gutenberg.
-MAGIC_HAMLET = b(
- "To be, or not to be,--that is the question:--\n"
- "Whether 'tis nobler in the mind to suffer\n"
- "The slings and arrows of outrageous fortune\n"
- "Or to take arms against a sea of troubles,\n"
- "And by opposing end them?--To die,--to sleep,--\n"
- "No more; and by a sleep to say we end\n"
- "The heartache, and the thousand natural shocks\n"
- "That flesh is heir to,--'tis a consummation\n"
- "Devoutly to be wish'd. To die,--to sleep;--\n"
- "To sleep! perchance to dream:--ay, there's the rub;\n"
- "For in that sleep of death what dreams may come,\n"
- "When we have shuffled off this mortal coil,\n"
- "Must give us pause: there's the respect\n"
- "That makes calamity of so long life;\n"
- "For who would bear the whips and scorns of time,\n"
- "The oppressor's wrong, the proud man's contumely,\n"
- "The pangs of despis'd love, the law's delay,\n"
- "The insolence of office, and the spurns\n"
- "That patient merit of the unworthy takes,\n"
- "When he himself might his quietus make\n"
- "With a bare bodkin? who would these fardels bear,\n"
- "To grunt and sweat under a weary life,\n"
- "But that the dread of something after death,--\n"
- "The undiscover'd country, from whose bourn\n"
- "No traveller returns,--puzzles the will,\n"
- "And makes us rather bear those ills we have\n"
- "Than fly to others that we know not of?\n"
- "Thus conscience does make cowards of us all;\n"
- "And thus the native hue of resolution\n"
- "Is sicklied o'er with the pale cast of thought;\n"
- "And enterprises of great pith and moment,\n"
- "With this regard, their currents turn awry,\n"
- "And lose the name of action.--Soft you now!\n"
- "The fair Ophelia!--Nymph, in thy orisons\n"
- "Be all my sins remember'd.\n\x00" #<- apparently null at end of C string is included (test vector won't pass otherwise)
+MAGIC_HAMLET = (
+ b"To be, or not to be,--that is the question:--\n"
+ b"Whether 'tis nobler in the mind to suffer\n"
+ b"The slings and arrows of outrageous fortune\n"
+ b"Or to take arms against a sea of troubles,\n"
+ b"And by opposing end them?--To die,--to sleep,--\n"
+ b"No more; and by a sleep to say we end\n"
+ b"The heartache, and the thousand natural shocks\n"
+ b"That flesh is heir to,--'tis a consummation\n"
+ b"Devoutly to be wish'd. To die,--to sleep;--\n"
+ b"To sleep! perchance to dream:--ay, there's the rub;\n"
+ b"For in that sleep of death what dreams may come,\n"
+ b"When we have shuffled off this mortal coil,\n"
+ b"Must give us pause: there's the respect\n"
+ b"That makes calamity of so long life;\n"
+ b"For who would bear the whips and scorns of time,\n"
+ b"The oppressor's wrong, the proud man's contumely,\n"
+ b"The pangs of despis'd love, the law's delay,\n"
+ b"The insolence of office, and the spurns\n"
+ b"That patient merit of the unworthy takes,\n"
+ b"When he himself might his quietus make\n"
+ b"With a bare bodkin? who would these fardels bear,\n"
+ b"To grunt and sweat under a weary life,\n"
+ b"But that the dread of something after death,--\n"
+ b"The undiscover'd country, from whose bourn\n"
+ b"No traveller returns,--puzzles the will,\n"
+ b"And makes us rather bear those ills we have\n"
+ b"Than fly to others that we know not of?\n"
+ b"Thus conscience does make cowards of us all;\n"
+ b"And thus the native hue of resolution\n"
+ b"Is sicklied o'er with the pale cast of thought;\n"
+ b"And enterprises of great pith and moment,\n"
+ b"With this regard, their currents turn awry,\n"
+ b"And lose the name of action.--Soft you now!\n"
+ b"The fair Ophelia!--Nymph, in thy orisons\n"
+ b"Be all my sins remember'd.\n\x00" #<- apparently null at end of C string is included (test vector won't pass otherwise)
)
# NOTE: these sequences are pre-calculated iteration ranges used by X & Y loops w/in rounds function below
diff --git a/passlib/handlers/windows.py b/passlib/handlers/windows.py
index 3f911be..ef246eb 100644
--- a/passlib/handlers/windows.py
+++ b/passlib/handlers/windows.py
@@ -4,13 +4,12 @@
#=============================================================================
# core
from binascii import hexlify
-import re
import logging; log = logging.getLogger(__name__)
from warnings import warn
# site
# pkg
from passlib.utils import to_unicode, right_pad_string
-from passlib.utils.compat import b, bytes, str_to_uascii, u, unicode, uascii_to_str
+from passlib.utils.compat import unicode
from passlib.utils.md4 import md4
import passlib.utils.handlers as uh
# local
@@ -62,7 +61,7 @@ class lmhash(uh.HasEncodingContext, uh.StaticHandler):
return hexlify(self.raw(secret, self.encoding)).decode("ascii")
# magic constant used by LMHASH
- _magic = b("KGS!@#$%")
+ _magic = b"KGS!@#$%"
@classmethod
def raw(cls, secret, encoding=None):
diff --git a/passlib/hash.py b/passlib/hash.py
index 8f1b895..ec601c1 100644
--- a/passlib/hash.py
+++ b/passlib/hash.py
@@ -9,11 +9,11 @@ This proxy object (passlib.registry._PasslibRegistryProxy)
handles lazy-loading hashes as they are requested.
The actual implementation of the various hashes is store elsewhere,
-mainly in the submodules of the ``passlib.handlers`` package.
+mainly in the submodules of the ``passlib.handlers`` subpackage.
"""
-# NOTE: could support 'non-lazy' version which just imports
-# all schemes known to list_crypt_handlers()
+# XXX: if any platform has problem w/ lazy modules, could support 'non-lazy'
+# version which just imports all schemes known to list_crypt_handlers()
#=============================================================================
# import proxy object and replace this module
diff --git a/passlib/hosts.py b/passlib/hosts.py
index 7df3efd..01a2440 100644
--- a/passlib/hosts.py
+++ b/passlib/hosts.py
@@ -3,7 +3,6 @@
# imports
#=============================================================================
# core
-import sys
from warnings import warn
# pkg
from passlib.context import LazyCryptContext
diff --git a/passlib/ifc.py b/passlib/ifc.py
index 5e4e7d4..58931ab 100644
--- a/passlib/ifc.py
+++ b/passlib/ifc.py
@@ -13,22 +13,10 @@ __all__ = [
]
#=============================================================================
-# 2.5-3.2 compatibility helpers
+# 2/3 compatibility helpers
#=============================================================================
-if sys.version_info >= (2,6):
- from abc import ABCMeta, abstractmethod, abstractproperty
-else:
- # create stub for python 2.5
- ABCMeta = type
- def abstractmethod(func):
- return func
-# def abstractproperty():
-# return None
-
-def create_with_metaclass(meta):
+def recreate_with_metaclass(meta):
"""class decorator that re-creates class using metaclass"""
- # have to do things this way since abc not present in py25,
- # and py2/py3 have different ways of doing metaclasses.
def builder(cls):
if meta is type(cls):
return cls
@@ -38,6 +26,12 @@ def create_with_metaclass(meta):
#=============================================================================
# PasswordHash interface
#=============================================================================
+from abc import ABCMeta, abstractmethod, abstractproperty
+
+# TODO: make this actually use abstractproperty(),
+# now that we dropped py25, 'abc' is always available.
+
+@recreate_with_metaclass(ABCMeta)
class PasswordHash(object):
"""This class describes an abstract interface which all password hashes
in Passlib adhere to. Under Python 2.6 and up, this is an actual
@@ -94,6 +88,54 @@ class PasswordHash(object):
raise NotImplementedError("must be implemented by subclass")
#===================================================================
+ # configuration
+ #===================================================================
+ @classmethod
+ @abstractmethod
+ def using(cls, **kwds):
+ """
+ Return another hasher object (typically a subclass),
+ which integrates the configuration options specified by ``kwds``.
+
+ .. todo::
+
+ document which options are accepted.
+
+ :returns:
+ typically returns a subclass for most hasher implementations.
+
+ .. todo::
+
+ add this method to main documentation.
+ """
+ raise NotImplementedError("must be implemented by subclass")
+
+ #===================================================================
+ # migration
+ #===================================================================
+ @classmethod
+ def needs_update(cls, hash, secret=None):
+ """
+ check if hash configuration is outside desired bounds.
+
+ :param hash:
+ hash string to examine
+
+ :param secret:
+ optional secret known to have verified against the provided hash.
+ (this is used by some hashes to detect legacy algorithm mistakes).
+
+ :return:
+ whether secret needs re-hashing.
+
+ .. todo::
+
+ add this method to main documentation.
+ """
+ # by default, always report that we don't need update
+ return False
+
+ #===================================================================
# additional methods
#===================================================================
@classmethod
@@ -129,33 +171,6 @@ class PasswordHash(object):
## checksum_size
#---------------------------------------------------------------
- # CryptContext flags
- #---------------------------------------------------------------
-
- # hack for bsdi_crypt: if True, causes CryptContext to only generate
- # odd rounds values. assumed False if not defined.
- ## _avoid_even_rounds = False
-
- ##@classmethod
- ##def _bind_needs_update(cls, **setting_kwds):
- ## """return helper to detect hashes that need updating.
- ##
- ## if this method is defined, the CryptContext constructor
- ## will invoke it with the settings specified for the context.
- ## this method should return either ``None``, or a callable
- ## with the signature ``needs_update(hash,secret)->bool``.
- ##
- ## this ``needs_update`` function should return True if the hash
- ## should be re-encrypted, whether due to internal
- ## issues or the specified settings.
- ##
- ## CryptContext will automatically take care of deprecating
- ## hashes with insufficient rounds for classes which define fromstring()
- ## and a rounds attribute - though the requirements for this last
- ## part may change at some point.
- ## """
-
- #---------------------------------------------------------------
# experimental methods
#---------------------------------------------------------------
@@ -186,8 +201,6 @@ class PasswordHash(object):
# eoc
#===================================================================
-PasswordHash = create_with_metaclass(ABCMeta)(PasswordHash)
-
#=============================================================================
# eof
#=============================================================================
diff --git a/passlib/pwd.py b/passlib/pwd.py
new file mode 100644
index 0000000..befb9ac
--- /dev/null
+++ b/passlib/pwd.py
@@ -0,0 +1,653 @@
+"""passlib.pwd -- password generation helpers.
+
+current api
+===========
+generation
+ * frontend: generate()
+ * backends: PhraseGenerator(), WordGenerator()
+
+strength
+ * strength(), classify()
+ * XXX: consider redo-ing as returning an informational object,
+ ala ``https://github.com/lowe/zxcvbn``'s result object
+
+TODO
+====
+This module's design is in flux, and may be changed before release.
+The following known bits remain:
+
+misc
+----
+* the terminology about what's being measured by _average_entropy() etc
+ may not be correct. this needs fixing before the module is released.
+ need to find a good reference on information theory, to make sure terminology
+ and formulas are correct :)
+
+ * when researching, also need to find any papers attempting to measure
+ guessing entropy (w/ respect to cracker's attack model),
+ rather than entropy with respect to a particular password
+ generation algorithm.
+
+* add a "crack time" estimation to generate & classify?
+ might be useful to give people better idea of what measurements mean.
+
+generation
+----------
+* unittests for generation code
+* add create_generator() frontend?
+* straighten out any unicode issues this code may have.
+ - primarily, this should always return unicode (currently doesn't)
+* don't like existing wordsets.
+ - diceware has some weird bordercases that average users may not like
+ - electrum's set isn't large enough for these purposes
+ - looking into modified version of wordfrequency.info's 5k list
+ (could merge w/ diceware, and remove commonly used passwords)
+
+strength
+--------
+* unittests for strength measurement
+* improve api for strength measurement
+ * one issue: need way to indicate when measurement is a lower/upper
+ bound, rather than and/or the accuracy of a given measurement.
+ * need to present this in a way which makes it easy to write
+ a password-strength meter,
+ * yet can have it's scale tweaked if the heuristics are revised.
+ * could look at https://github.com/lowe/zxcvbn for some ideas.
+* add more strength measurement algorithms
+ * NIST 800-63 should be easy
+ * zxcvbn (https://tech.dropbox.com/2012/04/zxcvbn-realistic-password-strength-estimation/)
+ might also be good, and has approach similar to composite approach
+ i was already thinking about.
+ * passfault (https://github.com/c-a-m/passfault) looks *very* thorough,
+ but may have licensing issues, plus porting to python
+ looks like very big job :(
+ * give a look at running things through zlib - might be able to cheaply
+ catch extra redundancies.
+"""
+#=============================================================================
+# imports
+#=============================================================================
+from __future__ import division
+# core
+from collections import defaultdict
+from hashlib import sha256
+from itertools import chain
+from math import ceil, log as logf
+import logging; log = logging.getLogger(__name__)
+import os
+import zlib
+# site
+# pkg
+from passlib.utils.compat import PY3, irange, itervalues, u
+from passlib.utils import rng, getrandstr
+# local
+__all__ = [
+ 'generate',
+ 'strength',
+ 'classify',
+]
+
+#=============================================================================
+# constants
+#=============================================================================
+
+#: default entropy amount for generated passwords
+default_entropy = 48
+
+#: default threshold for rejecting low self-information sequences,
+#: measured as % of maximum possible self-information for string & alphabet size.
+#: (todo: defend this choice of value -- 'twas picked via experimentation)
+default_min_complexity = 0.4
+
+#: default presets
+default_charset = "safe52"
+default_wordset = "beale"
+
+#: dict of preset characters sets
+charsets = dict(
+ safe52='2346789ABCDEFGHJKMNPQRTUVWXYZabcdefghjkmnpqrstuvwxyz',
+)
+
+#: dict of preset word sets,
+#: values set to None are lazy-loaded from disk by _load_wordset()
+wordsets = dict(
+ diceware=None,
+ beale=None,
+ electrum=None,
+)
+
+#: sha256 digest for wordset files, used as sanity check by _load_wordset()
+_wordset_checksums = dict(
+ diceware="b39e6c367066a75208424cb591f64f188bb6ad69c61da52195718203c18b93d6",
+ beale="4b3ca06b22094df07b078e28f632845191ef927deb5a7c77b0f788b336fb80e6",
+ electrum="d4975b36cff7002332f6e8dff5477af52089c48ad6535272bbff6fb850ffe206",
+ )
+
+#: misc helper constants
+_PCW_MSG = "`preset`, `charset`, and `wordset` are mutually exclusive"
+_USPACE = u(" ")
+_UEMPTY = u("")
+
+#=============================================================================
+# internal helpers
+#=============================================================================
+
+# XXX: would this be more appropriate as _self_info()?
+def _average_entropy(source, total=False):
+ """returns the rate of self-information in a sequence of symbols,
+ (or total self-information if total=True).
+
+ this is eqvuialent to the average entropy of a given symbol,
+ using the sequence itself as the symbol probability distribution.
+ if all elements of the source are unique, this should equal
+ ``log(len(source), 2)``.
+
+ :arg source:
+ iterable containing 0+ symbols
+ :param total:
+ instead of returning average entropy rate,
+ return total self-information
+ :returns:
+ float bits
+ """
+ try:
+ size = len(source)
+ except TypeError:
+ # if len() doesn't work, calculate size by summing counts later
+ size = None
+ counts = defaultdict(int)
+ for char in source:
+ counts[char] += 1
+ if size is None:
+ values = counts.values()
+ size = sum(values)
+ else:
+ values = itervalues(counts)
+ if not size:
+ return 0
+ ### NOTE: below code performs the calculation
+ ### ``- sum(value / size * logf(value / size, 2) for value in values)``,
+ ### and then multplies by ``size`` if total is True,
+ ### it just does it with fewer operations.
+ tmp = sum(value * logf(value, 2) for value in values)
+ if total:
+ return size * logf(size, 2) - tmp
+ else:
+ return logf(size, 2) - tmp / size
+
+def _max_average_entropy(target, source):
+ """calculate maximum _average_entropy() of all possible
+ strings of length <target>, if drawn from a set of symbols
+ of size <source>.
+ """
+ # NOTE: this accomplishes it's purpose by assuming maximum self-information
+ # would be a string repeating all symbols ``floor(target/source)``
+ # times, followed by the first ``target % source`` symbols repeated
+ # once more.
+ assert target > 0
+ assert source > 0
+ if target < source:
+ # special case of general equation, to prevent intermediate DomainError.
+ return logf(target, 2)
+ else:
+ q, r = divmod(target, source)
+ p1 = (q + 1) / target
+ p2 = q / target
+ return -(r * p1 * logf(p1, 2) + (source - r) * p2 * logf(p2, 2))
+
+def _average_wordset_entropy(wordset):
+ """return the average entropy per character in a given wordset,
+ using each char's frequency in the wordset as the probability of occurrence.
+
+ :arg wordset:
+ iterable containing 1+ words, each of which are themselves
+ iterables containing 1+ characters.
+ :returns:
+ float bits of entropy
+ """
+ return _average_entropy(chain.from_iterable(wordset))
+
+def _load_wordset(name):
+ "helper load compressed wordset from package data"
+ # load wordset from data file
+ source = os.path.join(os.path.dirname(__file__), "_data",
+ "%s.wordset.z" % name)
+ with open(source, "rb") as fh:
+ data = fh.read()
+
+ # verify against checksum
+ try:
+ checksum = _wordset_checksums[name]
+ except KeyError: # pragma: no cover -- sanity check
+ raise AssertionError("no checksum for wordset: %r" % name)
+ if sha256(data).hexdigest() != checksum:
+ raise RuntimeError("%r wordset file corrupted" % name)
+
+ # decompress and return wordset
+ words = wordsets[name] = zlib.decompress(data).decode("utf-8").splitlines()
+ log.debug("loaded %d-element wordset from %r", len(words), source)
+ return words
+
+#=============================================================================
+# password generators
+#=============================================================================
+class SequenceGenerator(object):
+ """base class used by word & phrase generators.
+
+ These objects take a series of options, corresponding
+ to those of the :func:`generate` function.
+ They act as callables which can be used to generate a password
+ or a list of 1+ passwords. They also expose some read-only
+ informational attributes.
+
+ :param entropy:
+ Optionally specify the amount of entropy the resulting passwords
+ should contain (as measured with respect to the generator itself).
+ This will be used to autocalculate the required password size.
+
+ Also exposed as a readonly attribute.
+
+ :param size:
+ Optionally specify the size of password to generate,
+ measured in whatever symbols the subclass uses (characters or words).
+ Note that if both ``size`` and ``entropy`` are specified,
+ the larger requested size will be used.
+
+ Also exposed as a readonly attribute.
+
+ :param min_complexity:
+ By default, generators derived from this class will avoid
+ generating passwords with excessively high per-symbol redundancy
+ (e.g. ``aaaaaaaa``). This is done by rejecting any strings
+ whose self-information per symbol is below a certain
+ percentage of the maximum possible a given string and alphabet
+ size. This defaults to 40%, or ``min_complexity=0.4``.
+
+ .. autoattribute:: entropy_rate
+ """
+ #=============================================================================
+ # instance attrs
+ #=============================================================================
+
+ #: minimum complexity threshold for rejecting generating strings.
+ _min_entropy = None
+
+ #: entropy rate per symbol of generated password (character or word)
+ entropy_rate = None
+
+ #: requested size of final passwords
+ size = None
+
+ #: random number source to use
+ rng = rng
+
+ #=============================================================================
+ # init
+ #=============================================================================
+ def __init__(self, size=None, entropy=None, rng=None, min_complexity=None,
+ **kwds):
+ # NOTE: subclass should have already set .entropy_rate
+ if size is None:
+ size = 1
+ if entropy is None:
+ entropy = default_entropy
+ elif size < 1:
+ raise ValueError("`size` must be positive integer")
+ if entropy is not None:
+ if entropy <= 0:
+ raise ValueError("`entropy` must be positive number")
+ size = max(size, int(ceil(entropy / self.entropy_rate)))
+ self.size = size
+ if rng is not None:
+ self.rng = rng
+ if min_complexity is None:
+ min_complexity = default_min_complexity
+ if min_complexity < 0 or min_complexity > 1:
+ raise ValueError("min_complexity must be between 0 and 1")
+ self._max_entropy = _max_average_entropy(size, 2**self.entropy_rate)
+ self._min_entropy = min_complexity * self._max_entropy
+ super(SequenceGenerator, self).__init__(**kwds)
+
+ #=============================================================================
+ # helpers
+ #=============================================================================
+ @property
+ def entropy(self):
+ """entropy of generated passwords (
+ measured with respect to the generation scheme)"""
+ return self.size * self.entropy_rate
+
+ def _gen(self):
+ """main generation function"""
+ raise NotImplementedError("implement in subclass")
+
+ #=============================================================================
+ # iter & callable frontend
+ #=============================================================================
+ def __call__(self, count=None):
+ """create and return passwords"""
+ if count is None:
+ return self._gen()
+ else:
+ return [self._gen() for _ in irange(count)]
+
+ def __iter__(self):
+ return self
+
+ if PY3:
+ def __next__(self):
+ return self._gen()
+ else:
+ def next(self):
+ return self._gen()
+
+ #=============================================================================
+ # eoc
+ #=============================================================================
+
+class WordGenerator(SequenceGenerator):
+ """class which generates passwords by randomly choosing
+ from a string of unique characters.
+
+ :param charset:
+ charset to draw from.
+ :param preset:
+ name of preset charset to use instead of explict charset.
+ :param \*\*kwds:
+ all other keywords passed to :class:`SequenceGenerator`.
+
+ .. autoattribute:: charset
+ """
+ #=============================================================================
+ # instance attrs
+ #=============================================================================
+
+ #: charset used by this generator
+ charset = None
+
+ #=============================================================================
+ # init
+ #=============================================================================
+ def __init__(self, charset=None, preset=None, **kwds):
+ if not (charset or preset):
+ preset = default_charset
+ if preset:
+ if charset:
+ raise TypeError(_PCW_MSG)
+ charset = charsets[preset]
+ if len(set(charset)) != len(charset):
+ raise ValueError("`charset` cannot contain duplicate elements")
+ self.charset = charset
+ self.entropy_rate = logf(len(charset), 2)
+ super(WordGenerator, self).__init__(**kwds)
+ ##log.debug("WordGenerator(): entropy/char=%r", self.entropy_rate)
+
+ #=============================================================================
+ # helpers
+ #=============================================================================
+ def _gen(self):
+ while True:
+ secret = getrandstr(self.rng, self.charset, self.size)
+ # check that it satisfies minimum self-information limit
+ # set by min_complexity. i.e., reject strings like "aaaaaaaa"
+ if _average_entropy(secret) >= self._min_entropy:
+ return secret
+
+ #=============================================================================
+ # eoc
+ #=============================================================================
+
+class PhraseGenerator(SequenceGenerator):
+ """class which generates passphrases by randomly choosing
+ from a list of unique words.
+
+ :param wordset:
+ wordset to draw from.
+ :param preset:
+ name of preset wordlist to use instead of ``wordset``.
+ :param spaces:
+ whether to insert spaces between words in output (defaults to ``True``).
+ :param \*\*kwds:
+ all other keywords passed to :class:`SequenceGenerator`.
+
+ .. autoattribute:: wordset
+ """
+ #=============================================================================
+ # instance attrs
+ #=============================================================================
+
+ #: list of words to draw from
+ wordset = None
+
+ #: average entropy per char within wordset
+ _entropy_per_char = None
+
+ #: minimum size string this will output, to prevent low-entropy
+ #: phrases from leaking through.
+ _min_chars = None
+
+ #=============================================================================
+ # init
+ #=============================================================================
+ def __init__(self, wordset=None, preset=None, spaces=True, **kwds):
+ if not (wordset or preset):
+ preset = default_wordset
+ if preset:
+ if wordset:
+ raise TypeError(_PCW_MSG)
+ wordset = wordsets[preset]
+ if wordset is None:
+ wordset = _load_wordset(preset)
+ if len(set(wordset)) != len(wordset):
+ raise ValueError("`wordset` cannot contain duplicate elements")
+ if not isinstance(wordset, (list, tuple)):
+ wordset = tuple(wordset)
+ self.wordset = wordset
+ self.entropy_rate = logf(len(wordset), 2)
+ super(PhraseGenerator, self).__init__(**kwds)
+ # NOTE: regarding min_chars:
+ # in order to ensure a brute force attack against underlying
+ # charset isn't more successful than one against the wordset,
+ # we need to reject any passwords which contain so many short
+ # words that ``chars_in_phrase * entropy_per_char <
+ # words_in_phrase * entropy_per_word``.
+ # this is done by finding the minimum chars required to invalidate
+ # the inequality, and then rejecting any phrases that are shorter.
+ self._entropy_per_char = _average_wordset_entropy(wordset)
+ self._min_chars = int(self.entropy / self._entropy_per_char)
+ if spaces:
+ self._min_chars += self.size-1
+ self._sep = _USPACE
+ else:
+ self._sep = _UEMPTY
+ ##log.debug("PhraseGenerator(): entropy/word=%r entropy/char=%r min_chars=%r",
+ ## self.entropy_rate, self._entropy_per_char, self._min_chars)
+
+ #=============================================================================
+ # helpers
+ #=============================================================================
+ def _gen(self):
+ while True:
+ symbols = [self.rng.choice(self.wordset) for _ in irange(self.size)]
+ # check that it satisfies minimum self-information limit
+ # set by min_complexity. i.e., reject strings like "aaaaaaaa"
+ if _average_entropy(symbols) > self._min_entropy:
+ secret = self._sep.join(symbols)
+ # check that we don't fall below per-character limit
+ # on self information. see __init__ for explanation
+ if len(secret) >= self._min_chars:
+ return secret
+
+ #=============================================================================
+ # eoc
+ #=============================================================================
+
+def generate(size=None, entropy=None, count=None,
+ preset=None, charset=None, wordset=None,
+ **kwds):
+ """Generate one or more random password / passphrases.
+
+ This function uses :mod:`random.SystemRandom` to generate
+ one or more passwords; it can be configured to generate
+ alphanumeric passwords, or full english phrases.
+ The complexity of the password can be specified
+ by size, or by the desired amount of entropy.
+
+ Usage Example::
+
+ >>> # generate random english phrase with 48 bits of entropy
+ >>> from passlib import pwd
+ >>> pwd.generate()
+ 'cairn pen keys flaw'
+
+ >>> # generate a random alphanumeric string with default 52 bits of entropy
+ >>> pwd.generate(entropy=52, preset="safe52")
+ 'DnBHvDjMK6'
+
+ :param size:
+ Size of resulting password, measured in characters or words.
+ If omitted, the size is autocalculated based on the ``entropy`` parameter.
+
+ :param entropy:
+ Strength of resulting password, measured in bits of Shannon entropy
+ (defaults to 48).
+
+ Based on the mode in use, the ``size`` parameter will be
+ autocalculated so that that an attacker will need an average of
+ ``2**(entropy-1)`` attempts to correctly guess the password
+ (this measurement assumes the attacker knows the mode
+ and configuration options in use, but nothing of the RNG state).
+
+ If both ``entropy`` and ``size`` are specified,
+ the larger effective size will be used.
+
+ :param count:
+ By default this generates a single password.
+ However, if ``count`` is specified, it will return a list
+ containing ``count`` passwords instead.
+
+ :param preset:
+ Optionally use a pre-defined word-set or character-set
+ when generating a password. This option cannot be combined
+ with ``charset`` or ``wordset``; if all three are omitted,
+ this function defaults to ``preset="beale"``.
+
+ There are currently three presets available:
+
+ ``"safe52"``
+
+ preset which outputs random alphanumeric passwords,
+ using a 52-element character set containing the characters A-Z and 0-9,
+ except for ``1IiLl0OoS5`` (which were omitted due to their visual similarity).
+ This charset has ~5.7 bits of entropy per character.
+
+ ``"diceware"``
+
+ preset which outputs random english phrases,
+ drawn randomly from a list of 7776 english words set down
+ by the `Diceware <http://world.std.com/~reinhold/diceware.html>`_ project.
+ This wordset has ~12.9 bits of entropy per word.
+
+ ``"beale"``
+
+ variant of the Diceware wordlist as edited by
+ Alan Beale, also available from the diceware project.
+ This wordset has ~12.9 bits of entropy per word.
+
+ :param charset:
+ Optionally specifies a string of characters to use when randomly
+ generating a password. This option cannot be combined
+ with ``preset`` or ``wordset``.
+
+ :param wordset:
+ Optionally specifies a list/set of words to use when randomly
+ generating a passphrase. This option cannot be combined
+ with ``preset`` or ``charset``.
+
+ :param spaces:
+ When generating a passphrase, controls whether spaces
+ should be inserted between the words. Defaults to ``True``.
+
+ :returns:
+ :class:`!str` containing randomly generated password,
+ or list of 1+ passwords if ``count`` is specified.
+ """
+ # create generator from options
+ kwds.update(size=size, entropy=entropy)
+ if wordset:
+ # create generator from wordset
+ if preset or charset:
+ raise TypeError(_PCW_MSG)
+ gen = PhraseGenerator(wordset, **kwds)
+ elif charset:
+ # create generator from charset
+ if preset:
+ raise TypeError(_PCW_MSG)
+ gen = WordGenerator(charset, **kwds)
+ else:
+ # create generator from preset
+ kwds.update(preset=preset)
+ if not preset or preset in wordsets:
+ assert preset not in charsets
+ gen = PhraseGenerator(**kwds)
+ elif preset in charsets:
+ gen = WordGenerator(**kwds)
+ else:
+ raise KeyError("unknown preset: %r" % preset)
+
+ # return passwords
+ return gen(count)
+
+#=============================================================================
+# password strength measurement
+#=============================================================================
+def strength(symbols):
+ """
+ roughly estimate the strength of the password.
+ this is a bit better than just using len(password).
+
+ param symbols: a sequence of symbols (e.g. password string/unicode)
+ returns: password strength estimate [float]
+ """
+ return _average_entropy(symbols, total=True)
+
+CLASSIFICATIONS = [
+ (10, 0), # everything < 10 returns 0 (weak)
+ (20, 1), # 10 <= s < 20 returns 1 (maybe still too weak)
+ (None, 2), # everything else returns 2
+ # last tuple must be (None, MAXVAL)
+]
+
+def classify(symbols, classifications=CLASSIFICATIONS):
+ """
+ roughly classify the strength of the password.
+ this is a bit better than just using len(password).
+
+ :param symbols:
+ a sequence of symbols (e.g. password string/unicode)
+ :param classifications:
+ list of tuples with the format ``(limit, classification)``.
+
+ :returns:
+ classification value
+
+ Usage Example::
+
+ >>> from passlib import pwd
+ >>> pwd.classify("10011001")
+ 0
+ >>> pwd.classify("secret")
+ 1
+ >>> pwd.classify("Eer6aiya")
+ 2
+ """
+ s = strength(symbols)
+ for limit, classification in classifications:
+ if limit is None or s < limit:
+ return classification
+ else:
+ raise ValueError("classifications needs to end with a (None, MAXVAL) tuple")
+
+#=============================================================================
+# eof
+#=============================================================================
diff --git a/passlib/registry.py b/passlib/registry.py
index 1f07940..9d22e35 100644
--- a/passlib/registry.py
+++ b/passlib/registry.py
@@ -88,6 +88,7 @@ _locations = dict(
bsd_nthash = "passlib.handlers.windows",
bsdi_crypt = "passlib.handlers.des_crypt",
cisco_pix = "passlib.handlers.cisco",
+ cisco_asa = "passlib.handlers.cisco",
cisco_type7 = "passlib.handlers.cisco",
cta_pbkdf2_sha1 = "passlib.handlers.pbkdf2",
crypt16 = "passlib.handlers.des_crypt",
@@ -312,7 +313,7 @@ def get_crypt_handler(name, default=_UNSET):
pass
# normalize name (and if changed, check dict again)
- assert isinstance(name, native_string_types), "name must be str instance"
+ assert isinstance(name, native_string_types), "name must be string instance"
alt = name.replace("-","_").lower()
if alt != name:
warn("handler names should be lower-case, and use underscores instead "
diff --git a/passlib/tests/backports.py b/passlib/tests/backports.py
index 58ce18f..c93b599 100644
--- a/passlib/tests/backports.py
+++ b/passlib/tests/backports.py
@@ -10,12 +10,11 @@ import sys
##from warnings import warn
# site
# pkg
-from passlib.utils.compat import base_string_types
+from passlib.utils.compat import PY26
# local
__all__ = [
"TestCase",
"skip", "skipIf", "skipUnless"
- "catch_warnings",
]
#=============================================================================
@@ -23,307 +22,44 @@ __all__ = [
#=============================================================================
try:
import unittest2 as unittest
- ut_version = 2
except ImportError:
+ if PY26:
+ raise ImportError("Passlib's tests require 'unittest2' under Python 2.6 (as of Passlib 1.7)")
+ # python 2.7 and python 3.2 both have unittest2 features (at least, the ones we use)
import unittest
- if sys.version_info < (2,7) or (3,0) <= sys.version_info < (3,2):
- # older versions of python will need to install the unittest2
- # backport (named unittest2_3k for 3.0/3.1)
- ##warn("please install unittest2 for python %d.%d, it will be required "
- ## "as of passlib 1.x" % sys.version_info[:2])
- ut_version = 1
- else:
- ut_version = 2
#=============================================================================
-# backport SkipTest support using nose
+# unittest aliases
#=============================================================================
-if ut_version < 2:
- # used to provide replacement SkipTest() error
- from nose.plugins.skip import SkipTest
-
- # hack up something to simulate skip() decorator
- import functools
- def skip(reason):
- def decorator(test_item):
- if isinstance(test_item, type) and issubclass(test_item, unittest.TestCase):
- class skip_wrapper(test_item):
- def setUp(self):
- raise SkipTest(reason)
- else:
- @functools.wraps(test_item)
- def skip_wrapper(*args, **kwargs):
- raise SkipTest(reason)
- return skip_wrapper
- return decorator
-
- def skipIf(condition, reason):
- if condition:
- return skip(reason)
- else:
- return lambda item: item
-
- def skipUnless(condition, reason):
- if condition:
- return lambda item: item
- else:
- return skip(reason)
-
-else:
- skip = unittest.skip
- skipIf = unittest.skipIf
- skipUnless = unittest.skipUnless
+skip = unittest.skip
+skipIf = unittest.skipIf
+skipUnless = unittest.skipUnless
+SkipTest = unittest.SkipTest
#=============================================================================
# custom test harness
#=============================================================================
class TestCase(unittest.TestCase):
"""backports a number of unittest2 features in TestCase"""
+
#===================================================================
- # backport some methods from unittest2
+ # backport some unittest2 names
#===================================================================
- if ut_version < 2:
-
- #----------------------------------------------------------------
- # simplistic backport of addCleanup() framework
- #----------------------------------------------------------------
- _cleanups = None
-
- def addCleanup(self, function, *args, **kwds):
- queue = self._cleanups
- if queue is None:
- queue = self._cleanups = []
- queue.append((function, args, kwds))
-
- def doCleanups(self):
- queue = self._cleanups
- while queue:
- func, args, kwds = queue.pop()
- func(*args, **kwds)
-
- def tearDown(self):
- self.doCleanups()
- unittest.TestCase.tearDown(self)
-
- #----------------------------------------------------------------
- # backport skipTest (requires nose to work)
- #----------------------------------------------------------------
- def skipTest(self, reason):
- raise SkipTest(reason)
-
- #----------------------------------------------------------------
- # backport various assert tests added in unittest2
- #----------------------------------------------------------------
- def assertIs(self, real, correct, msg=None):
- if real is not correct:
- std = "got %r, expected would be %r" % (real, correct)
- msg = self._formatMessage(msg, std)
- raise self.failureException(msg)
-
- def assertIsNot(self, real, correct, msg=None):
- if real is correct:
- std = "got %r, expected would not be %r" % (real, correct)
- msg = self._formatMessage(msg, std)
- raise self.failureException(msg)
-
- def assertIsInstance(self, obj, klass, msg=None):
- if not isinstance(obj, klass):
- std = "got %r, expected instance of %r" % (obj, klass)
- msg = self._formatMessage(msg, std)
- raise self.failureException(msg)
-
- def assertAlmostEqual(self, first, second, places=None, msg=None, delta=None):
- """Fail if the two objects are unequal as determined by their
- difference rounded to the given number of decimal places
- (default 7) and comparing to zero, or by comparing that the
- between the two objects is more than the given delta.
-
- Note that decimal places (from zero) are usually not the same
- as significant digits (measured from the most signficant digit).
-
- If the two objects compare equal then they will automatically
- compare almost equal.
- """
- if first == second:
- # shortcut
- return
- if delta is not None and places is not None:
- raise TypeError("specify delta or places not both")
-
- if delta is not None:
- if abs(first - second) <= delta:
- return
-
- standardMsg = '%s != %s within %s delta' % (repr(first),
- repr(second),
- repr(delta))
- else:
- if places is None:
- places = 7
-
- if round(abs(second-first), places) == 0:
- return
-
- standardMsg = '%s != %s within %r places' % (repr(first),
- repr(second),
- places)
- msg = self._formatMessage(msg, standardMsg)
- raise self.failureException(msg)
-
- def assertLess(self, left, right, msg=None):
- if left >= right:
- std = "%r not less than %r" % (left, right)
- raise self.failureException(self._formatMessage(msg, std))
-
- def assertGreater(self, left, right, msg=None):
- if left <= right:
- std = "%r not greater than %r" % (left, right)
- raise self.failureException(self._formatMessage(msg, std))
-
- def assertGreaterEqual(self, left, right, msg=None):
- if left < right:
- std = "%r less than %r" % (left, right)
- raise self.failureException(self._formatMessage(msg, std))
-
- def assertIn(self, elem, container, msg=None):
- if elem not in container:
- std = "%r not found in %r" % (elem, container)
- raise self.failureException(self._formatMessage(msg, std))
-
- def assertNotIn(self, elem, container, msg=None):
- if elem in container:
- std = "%r unexpectedly in %r" % (elem, container)
- raise self.failureException(self._formatMessage(msg, std))
-
- #----------------------------------------------------------------
- # override some unittest1 methods to support _formatMessage
- #----------------------------------------------------------------
- def assertEqual(self, real, correct, msg=None):
- if real != correct:
- std = "got %r, expected would equal %r" % (real, correct)
- msg = self._formatMessage(msg, std)
- raise self.failureException(msg)
-
- def assertNotEqual(self, real, correct, msg=None):
- if real == correct:
- std = "got %r, expected would not equal %r" % (real, correct)
- msg = self._formatMessage(msg, std)
- raise self.failureException(msg)
#---------------------------------------------------------------
- # backport assertRegex() alias from 3.2 to 2.7/3.1
+ # backport assertRegex() alias from 3.2 to 2.7
+ # was present in 2.7 under an alternate name
#---------------------------------------------------------------
if not hasattr(unittest.TestCase, "assertRegex"):
- if hasattr(unittest.TestCase, "assertRegexpMatches"):
- # was present in 2.7/3.1 under name assertRegexpMatches
- assertRegex = unittest.TestCase.assertRegexpMatches
- else:
- # 3.0 and <= 2.6 didn't have this method at all
- def assertRegex(self, text, expected_regex, msg=None):
- """Fail the test unless the text matches the regular expression."""
- if isinstance(expected_regex, base_string_types):
- assert expected_regex, "expected_regex must not be empty."
- expected_regex = re.compile(expected_regex)
- if not expected_regex.search(text):
- msg = msg or "Regex didn't match: "
- std = '%r not found in %r' % (msg, expected_regex.pattern, text)
- raise self.failureException(self._formatMessage(msg, std))
+ assertRegex = unittest.TestCase.assertRegexpMatches
+
+ if not hasattr(unittest.TestCase, "assertRaisesRegex"):
+ assertRaisesRegex = unittest.TestCase.assertRaisesRegexp
#===================================================================
# eoc
#===================================================================
#=============================================================================
-# backport catch_warnings
-#=============================================================================
-try:
- from warnings import catch_warnings
-except ImportError:
- # catch_warnings wasn't added until py26.
- # this adds backported copy from py26's stdlib
- # so we can use it under py25.
-
- class WarningMessage(object):
-
- """Holds the result of a single showwarning() call."""
-
- _WARNING_DETAILS = ("message", "category", "filename", "lineno", "file",
- "line")
-
- def __init__(self, message, category, filename, lineno, file=None,
- line=None):
- local_values = locals()
- for attr in self._WARNING_DETAILS:
- setattr(self, attr, local_values[attr])
- self._category_name = category.__name__ if category else None
-
- def __str__(self):
- return ("{message : %r, category : %r, filename : %r, lineno : %s, "
- "line : %r}" % (self.message, self._category_name,
- self.filename, self.lineno, self.line))
-
-
- class catch_warnings(object):
-
- """A context manager that copies and restores the warnings filter upon
- exiting the context.
-
- The 'record' argument specifies whether warnings should be captured by a
- custom implementation of warnings.showwarning() and be appended to a list
- returned by the context manager. Otherwise None is returned by the context
- manager. The objects appended to the list are arguments whose attributes
- mirror the arguments to showwarning().
-
- The 'module' argument is to specify an alternative module to the module
- named 'warnings' and imported under that name. This argument is only useful
- when testing the warnings module itself.
-
- """
-
- def __init__(self, record=False, module=None):
- """Specify whether to record warnings and if an alternative module
- should be used other than sys.modules['warnings'].
-
- For compatibility with Python 3.0, please consider all arguments to be
- keyword-only.
-
- """
- self._record = record
- self._module = sys.modules['warnings'] if module is None else module
- self._entered = False
-
- def __repr__(self):
- args = []
- if self._record:
- args.append("record=True")
- if self._module is not sys.modules['warnings']:
- args.append("module=%r" % self._module)
- name = type(self).__name__
- return "%s(%s)" % (name, ", ".join(args))
-
- def __enter__(self):
- if self._entered:
- raise RuntimeError("Cannot enter %r twice" % self)
- self._entered = True
- self._filters = self._module.filters
- self._module.filters = self._filters[:]
- self._showwarning = self._module.showwarning
- if self._record:
- log = []
- def showwarning(*args, **kwargs):
-# self._showwarning(*args, **kwargs)
- log.append(WarningMessage(*args, **kwargs))
- self._module.showwarning = showwarning
- return log
- else:
- return None
-
- def __exit__(self, *exc_info):
- if not self._entered:
- raise RuntimeError("Cannot exit %r without entering first" % self)
- self._module.filters = self._filters
- self._module.showwarning = self._showwarning
-
-#=============================================================================
# eof
#=============================================================================
diff --git a/passlib/tests/test_apache.py b/passlib/tests/test_apache.py
index 1785a3e..70fcc1c 100644
--- a/passlib/tests/test_apache.py
+++ b/passlib/tests/test_apache.py
@@ -4,17 +4,15 @@
#=============================================================================
from __future__ import with_statement
# core
-import hashlib
from logging import getLogger
import os
-import time
# site
# pkg
from passlib import apache
from passlib.exc import MissingBackendError
-from passlib.utils.compat import irange, unicode
-from passlib.tests.utils import TestCase, get_file, set_file, catch_warnings, ensure_mtime_changed
-from passlib.utils.compat import b, bytes, u
+from passlib.utils.compat import irange
+from passlib.tests.utils import TestCase, get_file, set_file, ensure_mtime_changed
+from passlib.utils.compat import u
# module
log = getLogger(__name__)
@@ -34,35 +32,35 @@ class HtpasswdFileTest(TestCase):
descriptionPrefix = "HtpasswdFile"
# sample with 4 users
- sample_01 = b('user2:2CHkkwa2AtqGs\n'
- 'user3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=\n'
- 'user4:pass4\n'
- 'user1:$apr1$t4tc7jTh$GPIWVUo8sQKJlUdV8V5vu0\n')
+ sample_01 = (b'user2:2CHkkwa2AtqGs\n'
+ b'user3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=\n'
+ b'user4:pass4\n'
+ b'user1:$apr1$t4tc7jTh$GPIWVUo8sQKJlUdV8V5vu0\n')
# sample 1 with user 1, 2 deleted; 4 changed
- sample_02 = b('user3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=\nuser4:pass4\n')
+ sample_02 = b'user3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=\nuser4:pass4\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')
+ sample_03 = (b'user2:pass2x\n'
+ b'user3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=\n'
+ b'user4:pass4\n'
+ b'user1:$apr1$t4tc7jTh$GPIWVUo8sQKJlUdV8V5vu0\n'
+ b'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')
+ sample_04_utf8 = b'user\xc3\xa6:2CHkkwa2AtqGs\n'
+ sample_04_latin1 = b'user\xe6:2CHkkwa2AtqGs\n'
- sample_dup = b('user1:pass1\nuser1:pass2\n')
+ sample_dup = b'user1:pass1\nuser1:pass2\n'
# sample with bcrypt & sha256_crypt hashes
- sample_05 = b('user2:2CHkkwa2AtqGs\n'
- 'user3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=\n'
- 'user4:pass4\n'
- 'user1:$apr1$t4tc7jTh$GPIWVUo8sQKJlUdV8V5vu0\n'
- 'user5:$2a$12$yktDxraxijBZ360orOyCOePFGhuis/umyPNJoL5EbsLk.s6SWdrRO\n'
- 'user6:$5$rounds=110000$cCRp/xUUGVgwR4aP$'
- 'p0.QKFS5qLNRqw1/47lXYiAcgIjJK.WjCO8nrEKuUK.\n')
+ sample_05 = (b'user2:2CHkkwa2AtqGs\n'
+ b'user3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=\n'
+ b'user4:pass4\n'
+ b'user1:$apr1$t4tc7jTh$GPIWVUo8sQKJlUdV8V5vu0\n'
+ b'user5:$2a$12$yktDxraxijBZ360orOyCOePFGhuis/umyPNJoL5EbsLk.s6SWdrRO\n'
+ b'user6:$5$rounds=110000$cCRp/xUUGVgwR4aP$'
+ b'p0.QKFS5qLNRqw1/47lXYiAcgIjJK.WjCO8nrEKuUK.\n')
def test_00_constructor_autoload(self):
"""test constructor autoload"""
@@ -81,14 +79,14 @@ class HtpasswdFileTest(TestCase):
# check new=True
ht = apache.HtpasswdFile(path, new=True)
- self.assertEqual(ht.to_string(), b(""))
+ self.assertEqual(ht.to_string(), b"")
self.assertEqual(ht.path, path)
self.assertFalse(ht.mtime)
# check autoload=False (deprecated alias for new=True)
with self.assertWarningList("``autoload=False`` is deprecated"):
ht = apache.HtpasswdFile(path, autoload=False)
- self.assertEqual(ht.to_string(), b(""))
+ self.assertEqual(ht.to_string(), b"")
self.assertEqual(ht.path, path)
self.assertFalse(ht.mtime)
@@ -119,7 +117,7 @@ class HtpasswdFileTest(TestCase):
def test_01_delete_autosave(self):
path = self.mktemp()
- sample = b('user1:pass1\nuser2:pass2\n')
+ sample = b'user1:pass1\nuser2:pass2\n'
set_file(path, sample)
ht = apache.HtpasswdFile(path)
@@ -128,7 +126,7 @@ class HtpasswdFileTest(TestCase):
ht = apache.HtpasswdFile(path, autosave=True)
ht.delete("user1")
- self.assertEqual(get_file(path), b("user2:pass2\n"))
+ self.assertEqual(get_file(path), b"user2:pass2\n")
def test_02_set_password(self):
"""test set_password()"""
@@ -155,7 +153,7 @@ class HtpasswdFileTest(TestCase):
def test_02_set_password_autosave(self):
path = self.mktemp()
- sample = b('user1:pass1\n')
+ sample = b'user1:pass1\n'
set_file(path, sample)
ht = apache.HtpasswdFile(path)
@@ -164,7 +162,7 @@ class HtpasswdFileTest(TestCase):
ht = apache.HtpasswdFile(path, default_scheme="plaintext", autosave=True)
ht.set_password("user1", "pass2")
- self.assertEqual(get_file(path), b("user1:pass2\n"))
+ self.assertEqual(get_file(path), b"user1:pass2\n")
def test_02_set_password_default_scheme(self):
"""test set_password() -- default_scheme"""
@@ -229,12 +227,12 @@ class HtpasswdFileTest(TestCase):
set_file(path, "")
backdate_file_mtime(path, 5)
ha = apache.HtpasswdFile(path, default_scheme="plaintext")
- self.assertEqual(ha.to_string(), b(""))
+ self.assertEqual(ha.to_string(), b"")
# 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"))
+ self.assertEqual(ha.to_string(), b"user1:pass1\n")
# change file
set_file(path, self.sample_01)
@@ -279,7 +277,7 @@ class HtpasswdFileTest(TestCase):
# test save w/ explicit path
hb.save(path)
- self.assertEqual(get_file(path), b("user1:pass1\n"))
+ self.assertEqual(get_file(path), b"user1:pass1\n")
def test_07_encodings(self):
"""test 'encoding' kwd"""
@@ -294,7 +292,7 @@ class HtpasswdFileTest(TestCase):
# test deprecated encoding=None
with self.assertWarningList("``encoding=None`` is deprecated"):
ht = apache.HtpasswdFile.from_string(self.sample_04_utf8, encoding=None)
- self.assertEqual(ht.users(), [ b('user\xc3\xa6') ])
+ self.assertEqual(ht.users(), [ b'user\xc3\xa6' ])
# check sample latin-1
ht = apache.HtpasswdFile.from_string(self.sample_04_latin1,
@@ -304,12 +302,12 @@ class HtpasswdFileTest(TestCase):
def test_08_get_hash(self):
"""test get_hash()"""
ht = apache.HtpasswdFile.from_string(self.sample_01)
- self.assertEqual(ht.get_hash("user3"), b("{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo="))
- self.assertEqual(ht.get_hash("user4"), b("pass4"))
+ self.assertEqual(ht.get_hash("user3"), b"{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=")
+ self.assertEqual(ht.get_hash("user4"), b"pass4")
self.assertEqual(ht.get_hash("user5"), None)
with self.assertWarningList("find\(\) is deprecated"):
- self.assertEqual(ht.find("user4"), b("pass4"))
+ self.assertEqual(ht.find("user4"), b"pass4")
def test_09_to_string(self):
"""test to_string"""
@@ -320,7 +318,7 @@ class HtpasswdFileTest(TestCase):
# test blank
ht = apache.HtpasswdFile()
- self.assertEqual(ht.to_string(), b(""))
+ self.assertEqual(ht.to_string(), b"")
def test_10_repr(self):
ht = apache.HtpasswdFile("fakepath", autosave=True, new=True, encoding="latin-1")
@@ -328,14 +326,14 @@ class HtpasswdFileTest(TestCase):
def test_11_malformed(self):
self.assertRaises(ValueError, apache.HtpasswdFile.from_string,
- b('realm:user1:pass1\n'))
+ b'realm:user1:pass1\n')
self.assertRaises(ValueError, apache.HtpasswdFile.from_string,
- b('pass1\n'))
+ b'pass1\n')
def test_12_from_string(self):
# forbid path kwd
self.assertRaises(TypeError, apache.HtpasswdFile.from_string,
- b(''), path=None)
+ b'', path=None)
#===================================================================
# eoc
@@ -349,25 +347,25 @@ class HtdigestFileTest(TestCase):
descriptionPrefix = "HtdigestFile"
# sample with 4 users
- sample_01 = b('user2:realm:549d2a5f4659ab39a80dac99e159ab19\n'
- 'user3:realm:a500bb8c02f6a9170ae46af10c898744\n'
- 'user4:realm:ab7b5d5f28ccc7666315f508c7358519\n'
- 'user1:realm:2a6cf53e7d8f8cf39d946dc880b14128\n')
+ sample_01 = (b'user2:realm:549d2a5f4659ab39a80dac99e159ab19\n'
+ b'user3:realm:a500bb8c02f6a9170ae46af10c898744\n'
+ b'user4:realm:ab7b5d5f28ccc7666315f508c7358519\n'
+ b'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_02 = (b'user3:realm:a500bb8c02f6a9170ae46af10c898744\n'
+ b'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')
+ sample_03 = (b'user2:realm:5ba6d8328943c23c64b50f8b29566059\n'
+ b'user3:realm:a500bb8c02f6a9170ae46af10c898744\n'
+ b'user4:realm:ab7b5d5f28ccc7666315f508c7358519\n'
+ b'user1:realm:2a6cf53e7d8f8cf39d946dc880b14128\n'
+ b'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')
+ sample_04_utf8 = b'user\xc3\xa6:realm\xc3\xa6:549d2a5f4659ab39a80dac99e159ab19\n'
+ sample_04_latin1 = b'user\xe6:realm\xe6:549d2a5f4659ab39a80dac99e159ab19\n'
def test_00_constructor_autoload(self):
"""test constructor autoload"""
@@ -379,7 +377,7 @@ class HtdigestFileTest(TestCase):
# check without autoload
ht = apache.HtdigestFile(path, new=True)
- self.assertEqual(ht.to_string(), b(""))
+ self.assertEqual(ht.to_string(), b"")
# check missing file
os.remove(path)
@@ -486,12 +484,12 @@ class HtdigestFileTest(TestCase):
set_file(path, "")
backdate_file_mtime(path, 5)
ha = apache.HtdigestFile(path)
- self.assertEqual(ha.to_string(), b(""))
+ self.assertEqual(ha.to_string(), b"")
# 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'))
+ self.assertEqual(ha.to_string(), b'user1:realm:2a6cf53e7d8f8cf39d946dc880b14128\n')
# change file
set_file(path, self.sample_01)
@@ -518,7 +516,7 @@ class HtdigestFileTest(TestCase):
set_file(path, "")
with self.assertWarningList(r"load\(force=False\) is deprecated"):
ha.load(force=False)
- self.assertEqual(ha.to_string(), b(""))
+ self.assertEqual(ha.to_string(), b"")
def test_06_save(self):
"""test save()"""
@@ -551,7 +549,7 @@ class HtdigestFileTest(TestCase):
self.assertEqual(ht.delete_realm("realm"), 4)
self.assertEqual(ht.realms(), [])
- self.assertEqual(ht.to_string(), b(""))
+ self.assertEqual(ht.to_string(), b"")
def test_08_get_hash(self):
"""test get_hash()"""
@@ -587,13 +585,13 @@ class HtdigestFileTest(TestCase):
# check blank
ht = apache.HtdigestFile()
- self.assertEqual(ht.to_string(), b(""))
+ self.assertEqual(ht.to_string(), b"")
def test_11_malformed(self):
self.assertRaises(ValueError, apache.HtdigestFile.from_string,
- b('realm:user1:pass1:other\n'))
+ b'realm:user1:pass1:other\n')
self.assertRaises(ValueError, apache.HtdigestFile.from_string,
- b('user1:pass1\n'))
+ b'user1:pass1\n')
#===================================================================
# eoc
diff --git a/passlib/tests/test_context.py b/passlib/tests/test_context.py
index d87bfa1..5dab88e 100644
--- a/passlib/tests/test_context.py
+++ b/passlib/tests/test_context.py
@@ -9,22 +9,19 @@ if PY3:
from configparser import NoSectionError
else:
from ConfigParser import NoSectionError
-import hashlib
+import datetime
import logging; log = logging.getLogger(__name__)
-import re
import os
-import time
import warnings
-import sys
# site
# pkg
from passlib import hash
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, unicode, str_to_uascii, PY2
+from passlib.exc import PasslibConfigWarning, PasslibHashWarning
+from passlib.utils import tick, to_unicode
+from passlib.utils.compat import irange, u, unicode, str_to_uascii, PY2, PY26
import passlib.utils.handlers as uh
-from passlib.tests.utils import TestCase, catch_warnings, set_file, TICK_RESOLUTION, quicksleep
+from passlib.tests.utils import TestCase, set_file, TICK_RESOLUTION, quicksleep
from passlib.registry import (register_crypt_handler_path,
_has_crypt_handler as has_crypt_handler,
_unload_handler_name as unload_handler_name,
@@ -822,11 +819,10 @@ sha512_crypt__min_rounds = 45000
dump = ctx.to_string()
# 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):
+ # NOTE: ConfigParser for PY26 doesn't use OrderedDict,
+ # making to_string()'s ordering unpredictable...
+ # so we skip this test under PY26.
+ if not PY26:
self.assertEqual(dump, self.sample_1_unicode)
# check ctx->string->ctx->dict returns original
@@ -982,7 +978,7 @@ sha512_crypt__min_rounds = 45000
with self.assertWarningList(PasslibConfigWarning):
self.assertEqual(
cc.encrypt("password", rounds=1999, salt="nacl"),
- '$5$rounds=2000$nacl$9/lTZ5nrfPuz8vphznnmHuDGFuvjSNvOEDsGmGfsS97',
+ '$5$rounds=1999$nacl$nmfwJIxqj0csloAAvSER0B8LU0ERCAbhmMug4Twl609',
)
with self.assertWarningList([]):
@@ -1106,22 +1102,16 @@ sha512_crypt__min_rounds = 45000
self.assertTrue(cc.needs_update('$5$rounds=3001$QlFHHifXvpFX4PLs$/0ekt7lSs/lOikSerQ0M/1porEHxYq7W/2hdFpxA3fA'))
#--------------------------------------------------------------
- # test _bind_needs_update() framework
+ # test hash.needs_update() interface
#--------------------------------------------------------------
- bind_state = []
check_state = []
class dummy(uh.StaticHandler):
name = 'dummy'
_hash_prefix = '@'
@classmethod
- def _bind_needs_update(cls, **settings):
- bind_state.append(settings)
- return cls._needs_update
-
- @classmethod
- def _needs_update(cls, hash, secret):
- check_state.append((hash,secret))
+ def needs_update(cls, hash, secret=None):
+ check_state.append((hash, secret))
return secret == "nu"
def _calc_checksum(self, secret):
@@ -1130,11 +1120,8 @@ sha512_crypt__min_rounds = 45000
secret = secret.encode("utf-8")
return str_to_uascii(md5(secret).hexdigest())
- # creating context should call bind function w/ settings
- ctx = CryptContext([dummy])
- self.assertEqual(bind_state, [{}])
-
# calling needs_update should query callback
+ ctx = CryptContext([dummy])
hash = refhash = dummy.encrypt("test")
self.assertFalse(ctx.needs_update(hash))
self.assertEqual(check_state, [(hash,None)])
@@ -1225,6 +1212,11 @@ sha512_crypt__min_rounds = 45000
#===================================================================
# rounds options
#===================================================================
+
+ # TODO: now that rounds generation has moved out of _CryptRecord to HasRounds,
+ # this should just test that we're passing right options to handler.using()...
+ # and let HasRounds tests (which are a copy of this) deal with things.
+
# 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.
@@ -1242,7 +1234,7 @@ sha512_crypt__min_rounds = 45000
#--------------------------------------------------
# set below handler minimum
- with self.assertWarningList([PasslibConfigWarning]*2):
+ with self.assertWarningList([PasslibHashWarning]*2):
c2 = cc.copy(all__min_rounds=500, all__max_rounds=None,
all__default_rounds=500)
self.assertEqual(c2.genconfig(salt="nacl"), "$5$rounds=1000$nacl$")
@@ -1251,7 +1243,7 @@ sha512_crypt__min_rounds = 45000
with self.assertWarningList(PasslibConfigWarning):
self.assertEqual(
cc.genconfig(rounds=1999, salt="nacl"),
- '$5$rounds=2000$nacl$',
+ '$5$rounds=1999$nacl$',
)
# equal to policy minimum
@@ -1271,7 +1263,7 @@ sha512_crypt__min_rounds = 45000
#--------------------------------------------------
# set above handler max
- with self.assertWarningList([PasslibConfigWarning]*2):
+ with self.assertWarningList([PasslibHashWarning]*2):
c2 = cc.copy(all__max_rounds=int(1e9)+500, all__min_rounds=None,
all__default_rounds=int(1e9)+500)
@@ -1282,7 +1274,7 @@ sha512_crypt__min_rounds = 45000
with self.assertWarningList(PasslibConfigWarning):
self.assertEqual(
cc.genconfig(rounds=3001, salt="nacl"),
- '$5$rounds=3000$nacl$'
+ '$5$rounds=3001$nacl$'
)
# equal policy max
@@ -1340,7 +1332,7 @@ sha512_crypt__min_rounds = 45000
self.assertRaises(ValueError, CryptContext, all__default_rounds='x')
# test bad types rejected
- bad = NotImplemented
+ bad = datetime.datetime.now() # picked cause can't be compared to int
self.assertRaises(TypeError, CryptContext, "sha256_crypt", all__min_rounds=bad)
self.assertRaises(TypeError, CryptContext, "sha256_crypt", all__max_rounds=bad)
self.assertRaises(TypeError, CryptContext, "sha256_crypt", all__vary_rounds=bad)
@@ -1434,72 +1426,6 @@ sha512_crypt__min_rounds = 45000
#===================================================================
# feature tests
#===================================================================
- def test_60_min_verify_time(self):
- """test verify() honors min_verify_time"""
- delta = .05
- if TICK_RESOLUTION >= delta/10:
- raise self.skipTest("timer not accurate enough")
- 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):
- quicksleep(self.delay)
- return to_unicode(secret + 'x')
-
- # check mvt issues a warning, and then filter for remainder of test
- with self.assertWarningList(["'min_verify_time' is deprecated"]*2):
- cc = CryptContext([TimedHash], min_verify_time=min_verify_time,
- admin__context__min_verify_time=min_verify_time*2)
- warnings.filterwarnings("ignore", "'min_verify_time' is deprecated")
-
- def timecall(func, *args, **kwds):
- start = tick()
- result = func(*args, **kwds)
- return tick()-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
-
- # correct password
- elapsed, result = timecall(cc.verify, "stub", "stubx")
- self.assertTrue(result)
- self.assertAlmostEqual(elapsed, min_delay, delta=delta)
-
- # incorrect password
- elapsed, result = timecall(cc.verify, "blob", "stubx")
- self.assertFalse(result)
- self.assertAlmostEqual(elapsed, min_verify_time, delta=delta)
-
- # incorrect password w/ special category setting
- elapsed, result = timecall(cc.verify, "blob", "stubx", category="admin")
- self.assertFalse(result)
- self.assertAlmostEqual(elapsed, min_verify_time*2, delta=delta)
-
- # ensure taking longer emits a warning.
- TimedHash.delay = max_delay
- with self.assertWarningList(".*verify exceeded min_verify_time"):
- elapsed, result = timecall(cc.verify, "blob", "stubx")
- self.assertFalse(result)
- self.assertAlmostEqual(elapsed, max_delay, delta=delta)
-
- # reject values < 0
- self.assertRaises(ValueError, CryptContext, min_verify_time=-1)
-
def test_61_autodeprecate(self):
"""test deprecated='auto' is handled correctly"""
diff --git a/passlib/tests/test_context_deprecated.py b/passlib/tests/test_context_deprecated.py
index df9e1b3..b59d0d8 100644
--- a/passlib/tests/test_context_deprecated.py
+++ b/passlib/tests/test_context_deprecated.py
@@ -10,12 +10,9 @@ it's being preserved here to ensure the old api doesn't break
#=============================================================================
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
@@ -24,15 +21,12 @@ except ImportError:
# 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, bytes
+from passlib.utils import to_bytes, to_unicode
import passlib.utils.handlers as uh
-from passlib.tests.utils import TestCase, catch_warnings, set_file
+from passlib.tests.utils import TestCase, set_file
from passlib.registry import (register_crypt_handler_path,
_has_crypt_handler as has_crypt_handler,
_unload_handler_name as unload_handler_name,
- get_crypt_handler,
)
# module
log = getLogger(__name__)
@@ -509,16 +503,8 @@ admin__context__deprecated = des_crypt, bsdi_crypt
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)
+ self.assertEqual(pb.get_min_verify_time(), 0)
+ self.assertEqual(pb.get_min_verify_time('admin'), 0)
#===================================================================
# serialization
diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py
index d89b2b4..9d0c3e3 100644
--- a/passlib/tests/test_ext_django.py
+++ b/passlib/tests/test_ext_django.py
@@ -9,7 +9,6 @@ from __future__ import absolute_import
# core
import logging; log = logging.getLogger(__name__)
import sys
-import warnings
# site
# pkg
from passlib.apps import django10_context, django14_context, django16_context
@@ -17,30 +16,21 @@ from passlib.context import CryptContext
import passlib.exc as exc
from passlib.utils.compat import iteritems, unicode, get_method_function, u, PY3
from passlib.utils import memoized_property
-from passlib.registry import get_crypt_handler
# tests
-from passlib.tests.utils import TestCase, skipUnless, catch_warnings, TEST_MODE, has_active_backend
+from passlib.tests.utils import TestCase, skipUnless, TEST_MODE, has_active_backend
from passlib.tests.test_handlers import get_handler_case
# local
#=============================================================================
# configure django settings for testcases
#=============================================================================
-from passlib.ext.django.utils import DJANGO_VERSION
+from passlib.ext.django.utils import DJANGO_VERSION, MIN_DJANGO_VERSION
-# disable all Django integration tests under py3,
-# since Django doesn't support py3 yet.
-if PY3 and DJANGO_VERSION < (1,5):
- DJANGO_VERSION = ()
-
-# convert django version to some cheap flags
-has_django = bool(DJANGO_VERSION)
-has_django0 = has_django and DJANGO_VERSION < (1,0)
-has_django1 = DJANGO_VERSION >= (1,0)
-has_django14 = DJANGO_VERSION >= (1,4)
+# whether we have supported django version
+has_min_django = DJANGO_VERSION >= MIN_DJANGO_VERSION
# import and configure empty django settings
-if has_django:
+if has_min_django:
from django.conf import settings, LazySettings
if not isinstance(settings, LazySettings):
@@ -49,11 +39,7 @@ if has_django:
raise RuntimeError("expected django.conf.settings to be LazySettings: %r" % (settings,))
# else configure a blank settings instance for the unittests
- if has_django0:
- if settings._target is None:
- from django.conf import UserSettingsHolder, global_settings
- settings._target = UserSettingsHolder(global_settings)
- elif not settings.configured:
+ if not settings.configured:
settings.configure()
#=============================================================================
@@ -68,14 +54,11 @@ def update_settings(**kwds):
for k,v in iteritems(kwds):
if v is UNSET:
if hasattr(settings, k):
- if has_django0:
- delattr(settings._target, k)
- else:
- delattr(settings, k)
+ delattr(settings, k)
else:
setattr(settings, k, v)
-if has_django:
+if has_min_django:
from django.contrib.auth.models import User
class FakeUser(User):
@@ -111,52 +94,29 @@ def create_mock_setter():
#=============================================================================
# work up stock django config
#=============================================================================
-sample_hashes = {} # override sample hashes used in test cases
-if DJANGO_VERSION >= (1,8):
- stock_config = django16_context.to_dict()
- stock_config.update(
- deprecated="auto",
- django_pbkdf2_sha1__default_rounds=20000,
- django_pbkdf2_sha256__default_rounds=20000,
- )
- sample_hashes.update(
- django_pbkdf2_sha256=("not a password", "pbkdf2_sha256$20000$arJ31mmmlSmO$XNBTUKe4UCUGPeHTmXpYjaKmJaDGAsevd0LWvBtzP18="),
- )
-elif DJANGO_VERSION >= (1,7):
- stock_config = django16_context.to_dict()
- stock_config.update(
- deprecated="auto",
- django_pbkdf2_sha1__default_rounds=15000,
- django_pbkdf2_sha256__default_rounds=15000,
- )
- sample_hashes.update(
- django_pbkdf2_sha256=("not a password", "pbkdf2_sha256$15000$xb2YnidpItz1$uHvLChIjUDc5HVUfQnE6lDMbgkTAiSYknGCtjuX4AVo="),
- )
-elif DJANGO_VERSION >= (1,6):
- stock_config = django16_context.to_dict()
- stock_config.update(
- deprecated="auto",
- django_pbkdf2_sha1__default_rounds=12000,
- django_pbkdf2_sha256__default_rounds=12000,
- )
- sample_hashes.update(
- django_pbkdf2_sha256=("not a password", "pbkdf2_sha256$12000$rpUPFQOVetrY$cEcWG4DjjDpLrDyXnduM+XJUz25U63RcM3//xaFnBnw="),
- )
-elif DJANGO_VERSION >= (1,4):
- stock_config = django14_context.to_dict()
- stock_config.update(
- deprecated="auto",
- django_pbkdf2_sha1__default_rounds=10000,
- django_pbkdf2_sha256__default_rounds=10000,
- )
-elif DJANGO_VERSION >= (1,0):
- stock_config = django10_context.to_dict()
-else:
- # 0.9.6 config
- stock_config = dict(
- schemes=["django_salted_sha1", "django_salted_md5", "hex_md5"],
- deprecated=["hex_md5"]
- )
+
+# build config dict that matches stock django
+# TODO: move these to passlib.apps
+if DJANGO_VERSION >= (1, 8):
+ stock_rounds = 20000
+elif DJANGO_VERSION >= (1, 7):
+ stock_rounds = 15000
+else: # 1.6
+ stock_rounds = 12000
+
+stock_config = django16_context.to_dict()
+stock_config.update(
+ deprecated="auto",
+ django_pbkdf2_sha1__default_rounds=stock_rounds,
+ django_pbkdf2_sha256__default_rounds=stock_rounds,
+)
+
+# override sample hashes used in test cases
+from passlib.hash import django_pbkdf2_sha256
+sample_hashes = dict(
+ django_pbkdf2_sha256=("not a password", django_pbkdf2_sha256.encrypt("not a password",
+ rounds=stock_config.get("django_pbkdf2_sha256__default_rounds")))
+)
#=============================================================================
# test utils
@@ -178,17 +138,13 @@ class _ExtensionSupport(object):
"""
# XXX: this and assert_unpatched() could probably be refactored to use
# the PatchManager class to do the heavy lifting.
- from django.contrib.auth import models
+ from django.contrib.auth import models, hashers
user_attrs = ["check_password", "set_password"]
- model_attrs = ["check_password"]
- objs = [(models, model_attrs), (models.User, user_attrs)]
- if has_django14:
- from django.contrib.auth import hashers
- model_attrs.append("make_password")
- objs.append((hashers, ["check_password", "make_password",
- "get_hasher", "identify_hasher"]))
- if has_django0:
- user_attrs.extend(["has_usable_password", "set_unusable_password"])
+ model_attrs = ["check_password", "make_password"]
+ objs = [(models, model_attrs),
+ (models.User, user_attrs),
+ (hashers, ["check_password", "make_password", "get_hasher", "identify_hasher"]),
+ ]
for obj, patched in objs:
for attr in dir(obj):
if attr.startswith("_"):
@@ -284,8 +240,10 @@ class _ExtensionTest(TestCase, _ExtensionSupport):
self.require_TEST_MODE("default")
- if not has_django:
+ if not DJANGO_VERSION:
raise self.skipTest("Django not installed")
+ elif not has_min_django:
+ raise self.skipTest("Django version too old")
# reset to baseline, and verify it worked
self.unload_extension()
@@ -313,13 +271,8 @@ class DjangoBehaviorTest(_ExtensionTest):
def assert_unusable_password(self, user):
"""check that user object is set to 'unusable password' constant"""
- if DJANGO_VERSION >= (1,6):
- # 1.6 on adds a random(?) suffix
- self.assertTrue(user.password.startswith("!"))
- else:
- self.assertEqual(user.password, "!")
- if has_django1 or self.patched:
- self.assertFalse(user.has_usable_password())
+ self.assertTrue(user.password.startswith("!"))
+ self.assertFalse(user.has_usable_password())
self.assertEqual(user.pop_saved_passwords(), [])
def assert_valid_password(self, user, hash=UNSET, saved=None):
@@ -334,8 +287,7 @@ class DjangoBehaviorTest(_ExtensionTest):
self.assertNotEqual(user.password, None)
else:
self.assertEqual(user.password, hash)
- if has_django1 or self.patched:
- self.assertTrue(user.has_usable_password())
+ self.assertTrue(user.has_usable_password())
self.assertEqual(user.pop_saved_passwords(),
[] if saved is None else [saved])
@@ -368,21 +320,15 @@ class DjangoBehaviorTest(_ExtensionTest):
PASS1 = "toomanysecrets"
WRONG1 = "letmein"
- has_hashers = False
has_identify_hasher = False
- if has_django14:
- from passlib.ext.django.utils import hasher_to_passlib_name, passlib_to_hasher_name
- from django.contrib.auth.hashers import check_password, make_password, is_password_usable
- if patched or DJANGO_VERSION > (1,5):
- # identify_hasher()
- # django 1.4 -- not present
- # django 1.5 -- present (added in django ticket 18184)
- # passlib integration -- present even under 1.4
- from django.contrib.auth.hashers import identify_hasher
- has_identify_hasher = True
- hash_hashers = True
- else:
- from django.contrib.auth.models import check_password
+ from passlib.ext.django.utils import hasher_to_passlib_name, passlib_to_hasher_name
+ from django.contrib.auth.hashers import check_password, make_password, is_password_usable
+ # identify_hasher()
+ # django 1.4 -- not present
+ # django 1.5 -- present (added in django ticket 18184)
+ # passlib integration -- present even under 1.4
+ from django.contrib.auth.hashers import identify_hasher
+ has_identify_hasher = True
#=======================================================
# make sure extension is configured correctly
@@ -394,9 +340,8 @@ class DjangoBehaviorTest(_ExtensionTest):
ctx.to_dict(resolve=True))
# should have patched both places
- if has_django14:
- from django.contrib.auth.models import check_password as check_password2
- self.assertIs(check_password2, check_password)
+ from django.contrib.auth.models import check_password as check_password2
+ self.assertIs(check_password2, check_password)
#=======================================================
# default algorithm
@@ -410,102 +355,63 @@ class DjangoBehaviorTest(_ExtensionTest):
# User.check_password() - n/a
# make_password() should use default alg
- if has_django14:
- hash = make_password(PASS1)
- self.assertTrue(ctx.handler().verify(PASS1, hash))
+ hash = make_password(PASS1)
+ self.assertTrue(ctx.handler().verify(PASS1, hash))
# check_password() - n/a
#=======================================================
# empty password behavior
#=======================================================
- if (1,4) <= DJANGO_VERSION < (1,6):
- # NOTE: django 1.4-1.5 treat empty password as invalid
- # User.set_password() should set unusable flag
- user = FakeUser()
- user.set_password('')
- self.assert_unusable_password(user)
+ # User.set_password() should use default alg
+ user = FakeUser()
+ user.set_password('')
+ hash = user.password
+ self.assertTrue(ctx.handler().verify('', hash))
+ self.assert_valid_password(user, hash)
- # User.check_password() should never return True
- user = FakeUser()
- user.password = hash = ctx.encrypt("")
- self.assertFalse(user.check_password(""))
- self.assert_valid_password(user, hash)
+ # User.check_password() should return True
+ self.assertTrue(user.check_password(""))
+ self.assert_valid_password(user, hash)
- # make_password() should reject empty passwords
- self.assertEqual(make_password(""), "!")
+ # no make_password()
- # check_password() should never return True
- self.assertFalse(check_password("", hash))
+ # check_password() should return True
+ self.assertTrue(check_password("", hash))
- else:
- # User.set_password() should use default alg
- user = FakeUser()
- user.set_password('')
- hash = user.password
- self.assertTrue(ctx.handler().verify('', hash))
- self.assert_valid_password(user, hash)
+ #=======================================================
+ # 'unusable flag' behavior
+ #=======================================================
- # User.check_password() should return True
- self.assertTrue(user.check_password(""))
- self.assert_valid_password(user, hash)
+ # sanity check via user.set_unusable_password()
+ user = FakeUser()
+ user.set_unusable_password()
+ self.assert_unusable_password(user)
- # no make_password()
+ # ensure User.set_password() sets unusable flag
+ user = FakeUser()
+ user.set_password(None)
+ self.assert_unusable_password(user)
- # check_password() should return True
- self.assertTrue(check_password("", hash))
+ # User.check_password() should always fail
+ self.assertFalse(user.check_password(None))
+ self.assertFalse(user.check_password('None'))
+ self.assertFalse(user.check_password(''))
+ self.assertFalse(user.check_password(PASS1))
+ self.assertFalse(user.check_password(WRONG1))
+ self.assert_unusable_password(user)
- #=======================================================
- # 'unusable flag' behavior
- #=======================================================
- if has_django1 or patched:
+ # make_password() should also set flag
+ self.assertTrue(make_password(None).startswith("!"))
- # sanity check via user.set_unusable_password()
- user = FakeUser()
- user.set_unusable_password()
- self.assert_unusable_password(user)
+ # check_password() should return False (didn't handle disabled under 1.3)
+ self.assertFalse(check_password(PASS1, '!'))
- # ensure User.set_password() sets unusable flag
- user = FakeUser()
- user.set_password(None)
- if DJANGO_VERSION < (1,2):
- # would set password to hash of "None"
- self.assert_valid_password(user)
- else:
- self.assert_unusable_password(user)
-
- # User.check_password() should always fail
- if DJANGO_VERSION < (1,2):
- self.assertTrue(user.check_password(None))
- self.assertTrue(user.check_password('None'))
- self.assertFalse(user.check_password(''))
- self.assertFalse(user.check_password(PASS1))
- self.assertFalse(user.check_password(WRONG1))
- else:
- self.assertFalse(user.check_password(None))
- self.assertFalse(user.check_password('None'))
- self.assertFalse(user.check_password(''))
- self.assertFalse(user.check_password(PASS1))
- self.assertFalse(user.check_password(WRONG1))
- self.assert_unusable_password(user)
-
- # make_password() should also set flag
- if has_django14:
- if DJANGO_VERSION >= (1,6):
- self.assertTrue(make_password(None).startswith("!"))
- else:
- self.assertEqual(make_password(None), "!")
-
- # check_password() should return False (didn't handle disabled under 1.3)
- if has_django14 or patched:
- self.assertFalse(check_password(PASS1, '!'))
-
- # identify_hasher() and is_password_usable() should reject it
- if has_django14:
- self.assertFalse(is_password_usable(user.password))
- if has_identify_hasher:
- self.assertRaises(ValueError, identify_hasher, user.password)
+ # identify_hasher() and is_password_usable() should reject it
+ self.assertFalse(is_password_usable(user.password))
+ if has_identify_hasher:
+ self.assertRaises(ValueError, identify_hasher, user.password)
#=======================================================
# hash=None
@@ -515,23 +421,13 @@ class DjangoBehaviorTest(_ExtensionTest):
# User.check_password() - returns False
user = FakeUser()
user.password = None
- if has_django14 or patched:
- self.assertFalse(user.check_password(PASS1))
- else:
- self.assertRaises(TypeError, user.check_password, PASS1)
- if has_django1 or patched:
- if DJANGO_VERSION < (1,2):
- self.assertTrue(user.has_usable_password())
- else:
- self.assertFalse(user.has_usable_password())
+ self.assertFalse(user.check_password(PASS1))
+ self.assertFalse(user.has_usable_password())
# make_password() - n/a
# check_password() - error
- if has_django14 or patched:
- self.assertFalse(check_password(PASS1, None))
- else:
- self.assertRaises(AttributeError, check_password, PASS1, None)
+ self.assertFalse(check_password(PASS1, None))
# identify_hasher() - error
if has_identify_hasher:
@@ -550,46 +446,28 @@ class DjangoBehaviorTest(_ExtensionTest):
# User.check_password()
# empty
# -----
- # django 1.3 and earlier -- blank hash returns False
# django 1.4 -- blank threw error (fixed in 1.5)
# django 1.5 -- blank hash returns False
#
# invalid
# -------
- # django 1.4 and earlier -- invalid hash threw error (fixed in 1.5)
+ # django 1.4 -- invalid hash threw error (fixed in 1.5)
# django 1.5 -- invalid hash returns False
user = FakeUser()
user.password = hash
- if DJANGO_VERSION >= (1,5) or (not hash and DJANGO_VERSION < (1,4)):
- # returns False for hash
- self.assertFalse(user.check_password(PASS1))
- else:
- # throws error for hash
- self.assertRaises(ValueError, user.check_password, PASS1)
+ self.assertFalse(user.check_password(PASS1))
# verify hash wasn't changed/upgraded during check_password() call
self.assertEqual(user.password, hash)
self.assertEqual(user.pop_saved_passwords(), [])
# User.has_usable_password()
- # passlib shim for django 0.x -- invalid/empty usable, to match 1.0-1.4
- # django 1.0-1.4 -- invalid/empty usable (fixed in 1.5)
- # django 1.5 -- invalid/empty no longer usable
- if has_django1 or self.patched:
- if DJANGO_VERSION < (1,5):
- self.assertTrue(user.has_usable_password())
- else:
- self.assertFalse(user.has_usable_password())
+ self.assertFalse(user.has_usable_password())
# make_password() - n/a
# check_password()
- # django 1.4 and earlier -- invalid/empty hash threw error (fixed in 1.5)
- # django 1.5 -- invalid/empty hash now returns False
- if DJANGO_VERSION < (1,5):
- self.assertRaises(ValueError, check_password, PASS1, hash)
- else:
- self.assertFalse(check_password(PASS1, hash))
+ self.assertFalse(check_password(PASS1, hash))
# identify_hasher() - throws error
if has_identify_hasher:
@@ -637,17 +515,12 @@ class DjangoBehaviorTest(_ExtensionTest):
user.password = hash
# check against invalid password
- if has_django1 or patched:
- self.assertFalse(user.check_password(None))
- else:
- self.assertRaises(TypeError, user.check_password, None)
+ self.assertFalse(user.check_password(None))
##self.assertFalse(user.check_password(''))
self.assertFalse(user.check_password(other))
self.assert_valid_password(user, hash)
# check against valid password
- if has_django0 and isinstance(secret, unicode):
- secret = secret.encode("utf-8")
self.assertTrue(user.check_password(secret))
# check if it upgraded the hash
@@ -668,30 +541,23 @@ class DjangoBehaviorTest(_ExtensionTest):
#-------------------------------------------------------
# make_password() correctly selects algorithm
#-------------------------------------------------------
- if has_django14:
- hash2 = make_password(secret, hasher=passlib_to_hasher_name(scheme))
- self.assertTrue(handler.verify(secret, hash2))
+ hash2 = make_password(secret, hasher=passlib_to_hasher_name(scheme))
+ self.assertTrue(handler.verify(secret, hash2))
#-------------------------------------------------------
# check_password()+setter against known hash
#-------------------------------------------------------
- if has_django14 or patched:
- # should call setter only if it needs_update
- self.assertTrue(check_password(secret, hash, setter=setter))
- self.assertEqual(setter.popstate(), [secret] if needs_update else [])
-
- # should not call setter
- self.assertFalse(check_password(other, hash, setter=setter))
- self.assertEqual(setter.popstate(), [])
+ # should call setter only if it needs_update
+ self.assertTrue(check_password(secret, hash, setter=setter))
+ self.assertEqual(setter.popstate(), [secret] if needs_update else [])
- ### check preferred kwd is ignored (django 1.4 feature we don't support)
- ##self.assertTrue(check_password(secret, hash, setter=setter, preferred='fooey'))
- ##self.assertEqual(setter.popstate(), [secret])
+ # should not call setter
+ self.assertFalse(check_password(other, hash, setter=setter))
+ self.assertEqual(setter.popstate(), [])
- elif patched or scheme != "hex_md5":
- # django 1.3 never called check_password() for hex_md5
- self.assertTrue(check_password(secret, hash))
- self.assertFalse(check_password(other, hash))
+ ### check preferred kwd is ignored (django 1.4 feature we don't support)
+ ##self.assertTrue(check_password(secret, hash, setter=setter, preferred='fooey'))
+ ##self.assertEqual(setter.popstate(), [secret])
# TODO: get_hasher()
@@ -781,8 +647,6 @@ class DjangoExtensionTest(_ExtensionTest):
def test_02_handler_wrapper(self):
"""test Hasher-compatible handler wrappers"""
- if not has_django14:
- raise self.skipTest("Django >= 1.4 not installed")
from passlib.ext.django.utils import get_passlib_hasher
from django.contrib.auth import hashers
@@ -833,12 +697,7 @@ class DjangoExtensionTest(_ExtensionTest):
"""test PASSLIB_CONFIG='<preset>'"""
# test django presets
self.load_extension(PASSLIB_CONTEXT="django-default", check=False)
- if DJANGO_VERSION >= (1,6):
- ctx = django16_context
- elif DJANGO_VERSION >= (1,4):
- ctx = django14_context
- else:
- ctx = django10_context
+ ctx = django16_context
self.assert_patched(ctx)
self.load_extension(PASSLIB_CONFIG="django-1.0", check=False)
@@ -926,6 +785,10 @@ class DjangoExtensionTest(_ExtensionTest):
# eoc
#===================================================================
+#=============================================================================
+# helpers for HashersTest below
+#=============================================================================
+
from passlib.context import CryptContext
class ContextWithHook(CryptContext):
"""subclass which invokes update_hook(self) before major actions"""
@@ -942,18 +805,30 @@ class ContextWithHook(CryptContext):
self.update_hook(self)
return super(ContextWithHook, self).verify(*args, **kwds)
+#=============================================================================
+# HashersTest --
# hack up the some of the real django tests to run w/ extension loaded,
# to ensure we mimic their behavior.
# however, the django tests were moved out of the package, and into a source-only location
# as of django 1.7. so we disable tests from that point on unless test-runner specifies
+#=============================================================================
+
+#
+# try to load django's tests/auth_tests/test_hasher.py module,
+# or note why we failed.
+#
+
test_hashers_mod = None
hashers_skip_msg = None
+
if TEST_MODE(max="quick"):
hashers_skip_msg = "requires >= 'default' test mode"
+
elif DJANGO_VERSION >= (1, 7):
import os
import sys
source_path = os.environ.get("PASSLIB_TESTS_DJANGO_SOURCE_PATH")
+
if source_path:
if not os.path.exists(source_path):
raise EnvironmentError("django source path not found: %r" % source_path)
@@ -965,23 +840,32 @@ elif DJANGO_VERSION >= (1, 7):
sys.path.insert(0, tests_path)
from auth_tests import test_hashers as test_hashers_mod
sys.path.remove(tests_path)
+
else:
hashers_skip_msg = "requires PASSLIB_TESTS_DJANGO_SOURCE_PATH to be set for django 1.7+"
+
elif DJANGO_VERSION >= (1, 6):
from django.contrib.auth.tests import test_hashers as test_hashers_mod
-elif DJANGO_VERSION >= (1, 4):
- from django.contrib.auth.tests import hashers as test_hashers_mod
+
+elif DJANGO_VERSION:
+ hashers_skip_msg = "django version too old"
+
else:
- hashers_skip_msg = "requires django 1.4+ to be present"
+ hashers_skip_msg = "django not installed"
-# hack up the some of the real django tests to run w/ extension loaded,
-# to ensure we mimic their behavior.
+#
+# if found module, create wrapper to run django's own tests w/ passlib monkeypatched in.
+#
if test_hashers_mod:
- from passlib.tests.utils import patchAttr
+ from passlib.utils.compat import get_unbound_method_function
class HashersTest(test_hashers_mod.TestUtilsHashPass, _ExtensionSupport):
"""run django's hasher unittests against passlib's extension
and workalike implementations"""
+
+ # port patchAttr() helper method from passlib.tests.utils.TestCase
+ patchAttr = get_unbound_method_function(TestCase.patchAttr)
+
def setUp(self):
# NOTE: omitted orig setup, want to install our extension,
# and load hashers through it instead.
@@ -994,24 +878,22 @@ if test_hashers_mod:
"check_password",
"identify_hasher",
"get_hasher"]:
- patchAttr(self, test_hashers_mod, attr, getattr(hashers, attr))
+ self.patchAttr(test_hashers_mod, attr, getattr(hashers, attr))
- # django 1.4 tests expect empty django_des_crypt salt field
- if DJANGO_VERSION >= (1,4):
- from passlib.hash import django_des_crypt
- patchAttr(self, django_des_crypt, "use_duplicate_salt", False)
+ # django tests expect empty django_des_crypt salt field
+ from passlib.hash import django_des_crypt
+ self.patchAttr(django_des_crypt, "use_duplicate_salt", False)
# hack: need password_context to keep up to date with hasher.iterations
- if DJANGO_VERSION >= (1,6):
- def update_hook(self):
- rounds = test_hashers_mod.get_hasher("pbkdf2_sha256").iterations
- self.update(
- django_pbkdf2_sha256__min_rounds=rounds,
- django_pbkdf2_sha256__default_rounds=rounds,
- django_pbkdf2_sha256__max_rounds=rounds,
- )
- patchAttr(self, password_context, "__class__", ContextWithHook)
- patchAttr(self, password_context, "update_hook", update_hook)
+ def update_hook(self):
+ rounds = test_hashers_mod.get_hasher("pbkdf2_sha256").iterations
+ self.update(
+ django_pbkdf2_sha256__min_rounds=rounds,
+ django_pbkdf2_sha256__default_rounds=rounds,
+ django_pbkdf2_sha256__max_rounds=rounds,
+ )
+ self.patchAttr(password_context, "__class__", ContextWithHook)
+ self.patchAttr(password_context, "update_hook", update_hook)
# omitting this test, since it depends on updated to django hasher settings
test_pbkdf2_upgrade_new_hasher = lambda self: self.skipTest("omitted by passlib")
@@ -1019,7 +901,10 @@ if test_hashers_mod:
def tearDown(self):
self.unload_extension()
super(HashersTest, self).tearDown()
+
else:
+ # otherwise leave a stub so test log tells why test was skipped.
+
class HashersTest(TestCase):
def test_external_django_hasher_tests(self):
diff --git a/passlib/tests/test_handlers.py b/passlib/tests/test_handlers.py
index 142e9e0..7992f82 100644
--- a/passlib/tests/test_handlers.py
+++ b/passlib/tests/test_handlers.py
@@ -4,7 +4,6 @@
#=============================================================================
from __future__ import with_statement
# core
-import hashlib
import logging; log = logging.getLogger(__name__)
import os
import sys
@@ -15,7 +14,7 @@ from passlib import hash
from passlib.utils import repeat_string
from passlib.utils.compat import irange, PY3, u, get_method_function
from passlib.tests.utils import TestCase, HandlerCase, skipUnless, \
- TEST_MODE, b, catch_warnings, UserHandlerMixin, randintgauss, EncodingHandlerMixin
+ TEST_MODE, UserHandlerMixin, randintgauss, EncodingHandlerMixin
# module
#=============================================================================
@@ -27,22 +26,22 @@ UPASS_WAV = u('\u0399\u03c9\u03b1\u03bd\u03bd\u03b7\u03c2')
UPASS_USD = u("\u20AC\u00A5$")
UPASS_TABLE = u("t\u00e1\u0411\u2113\u0259")
-PASS_TABLE_UTF8 = b('t\xc3\xa1\xd0\x91\xe2\x84\x93\xc9\x99') # utf-8
+PASS_TABLE_UTF8 = b't\xc3\xa1\xd0\x91\xe2\x84\x93\xc9\x99' # utf-8
+
+# handlers which support multiple backends, but don't have multi-backend tests.
+_omitted_backend_tests = ["django_bcrypt", "django_bcrypt_sha256"]
def get_handler_case(scheme):
"""return HandlerCase instance for scheme, used by other tests"""
from passlib.registry import get_crypt_handler
handler = get_crypt_handler(scheme)
- if hasattr(handler, "backends") and not hasattr(handler, "wrapped") and handler.name != "django_bcrypt_sha256":
+ if hasattr(handler, "backends") and scheme not in _omitted_backend_tests:
+ # NOTE: will throw MissingBackendError if none are installed.
backend = handler.get_backend()
name = "%s_%s_test" % (scheme, backend)
else:
name = "%s_test" % scheme
- try:
- return globals()[name]
- except KeyError:
- pass
- for suffix in ("handlers_django", "handlers_bcrypt"):
+ for suffix in ("handlers", "handlers_django", "handlers_bcrypt"):
modname = "passlib.tests.test_" + suffix
__import__(modname)
mod = sys.modules[modname]
@@ -169,8 +168,9 @@ class _bsdi_crypt_test(HandlerCase):
super(_bsdi_crypt_test, self).setUp()
warnings.filterwarnings("ignore", "bsdi_crypt rounds should be odd.*")
-bsdi_crypt_os_crypt_test, bsdi_crypt_builtin_test = \
- _bsdi_crypt_test.create_backend_cases(["os_crypt","builtin"])
+# create test cases for specific backends
+bsdi_crypt_os_crypt_test = _bsdi_crypt_test.create_backend_case("os_crypt")
+bsdi_crypt_builtin_test = _bsdi_crypt_test.create_backend_case("builtin")
#=============================================================================
# cisco pix
@@ -232,6 +232,88 @@ class cisco_pix_test(UserHandlerMixin, HandlerCase):
(UPASS_TABLE, 'CaiIvkLMu2TOHXGT'),
]
+def _get_secret(value):
+ """extract secret from secret or (secret, user) tuple"""
+ if isinstance(value, tuple):
+ return value[0]
+ else:
+ return value
+
+class cisco_asa_test(UserHandlerMixin, HandlerCase):
+ handler = hash.cisco_asa
+ secret_size = 32
+ requires_user = False
+
+
+ known_correct_hashes = [
+ # format: ((secret, user), hash)
+
+ #
+ # passlib test vectors
+ # TODO: these have not been confirmed by an outside source,
+ # nor tested against an official implementation.
+ # for now, these only confirm we haven't had a regression.
+ #
+
+ # 8 char password -- should be same as pix
+ (('01234567', ''), '0T52THgnYdV1tlOF'),
+ (('01234567', '36'), 'oY0Dh6RVC9KFlopL'),
+ (('01234567', 'user'), 'PNZ4ycbbZ0jp1.j1'),
+ (('01234567', 'user1234'), 'PNZ4ycbbZ0jp1.j1'),
+
+ # 12 char password -- should be same as pix
+ (('0123456789ab', ''), 'S31BxZOGlAigndcJ'),
+ (('0123456789ab', '36'), 'JqCXavOaaaTn9B5y'),
+ (('0123456789ab', 'user'), 'f.T4BKdzdNkjxQl7'),
+ (('0123456789ab', 'user1234'), 'f.T4BKdzdNkjxQl7'),
+
+ # 13 char password -- ASA should switch to larger padding
+ (('0123456789abc', ''), 'XGUn8JhVAnJsaJ69'), # e.g: cisco_pix is 'eacOpB7vE7ZDukSF'
+ (('0123456789abc', '36'), 'feNbQYEDXynZXMJH'),
+ (('0123456789abc', 'user'), '8Q/FZeam5ai1A47p'),
+ (('0123456789abc', 'user1234'), '8Q/FZeam5ai1A47p'),
+
+ # 16 char password -- verify fencepost
+ (('0123456789abcdef', ''), 'YO.dC.tE77bB35aH'),
+ (('0123456789abcdef', '36'), 'ekOxFx1Mqt8hL3vJ'),
+ (('0123456789abcdef', 'user'), 'IneB.wc9sfRzLPoh'),
+ (('0123456789abcdef', 'user1234'), 'IneB.wc9sfRzLPoh'),
+
+ # 27 char password -- ASA should still append user
+ (('0123456789abcdefqwertyuiopa', ''), '4wp19zS3OCe.2jt5'),
+ (('0123456789abcdefqwertyuiopa', '36'), 'GlGggqfEc19br12c'),
+ (('0123456789abcdefqwertyuiopa', 'user'), 'zynfWw3UtszxLMgL'),
+ (('0123456789abcdefqwertyuiopa', 'user1234'), 'zynfWw3UtszxLMgL'),
+
+ # 28 char password -- ASA shouldn't append user anymore
+ (('0123456789abcdefqwertyuiopas', ''), 'W6nbOddI0SutTK7m'),
+ (('0123456789abcdefqwertyuiopas', '36'), 'W6nbOddI0SutTK7m'),
+ (('0123456789abcdefqwertyuiopas', 'user'), 'W6nbOddI0SutTK7m'),
+ (('0123456789abcdefqwertyuiopas', 'user1234'), 'W6nbOddI0SutTK7m'),
+
+ # 32 char password -- verify fencepost
+ (('0123456789abcdefqwertyuiopasdfgh', ''), '5hPT/iC6DnoBxo6a'),
+ (('0123456789abcdefqwertyuiopasdfgh', '36'), '5hPT/iC6DnoBxo6a'),
+ (('0123456789abcdefqwertyuiopasdfgh', 'user'), '5hPT/iC6DnoBxo6a'),
+ (('0123456789abcdefqwertyuiopasdfgh', 'user1234'), '5hPT/iC6DnoBxo6a'),
+
+ # 33 char password -- ASA should truncate to 32 (should be same as above)
+ (('0123456789abcdefqwertyuiopasdfghj', ''), '5hPT/iC6DnoBxo6a'),
+ (('0123456789abcdefqwertyuiopasdfghj', '36'), '5hPT/iC6DnoBxo6a'),
+ (('0123456789abcdefqwertyuiopasdfghj', 'user'), '5hPT/iC6DnoBxo6a'),
+ (('0123456789abcdefqwertyuiopasdfghj', 'user1234'), '5hPT/iC6DnoBxo6a'),
+
+ # unicode password -- assumes cisco will use utf-8 encoding
+ ((u('t\xe1ble'), ''), 'xQXX755BKYRl0ZpQ'),
+ ((u('t\xe1ble'), '36'), 'Q/43xXKmIaKLycSj'),
+ ((u('t\xe1ble'), 'user'), 'Og8fB4NyF0m5Ed9c'),
+ ((u('t\xe1ble'), 'user1234'), 'Og8fB4NyF0m5Ed9c'),
+ ]
+
+ # append all the cisco_pix hashes w/ password < 13 chars ... those should be the same.
+ known_correct_hashes.extend(row for row in cisco_pix_test.known_correct_hashes
+ if len(_get_secret(row[0])) < 13)
+
#=============================================================================
# cisco type 7
#=============================================================================
@@ -389,8 +471,9 @@ class _des_crypt_test(HandlerCase):
("freebsd|openbsd|netbsd|linux|solaris|darwin", True),
]
-des_crypt_os_crypt_test, des_crypt_builtin_test = \
- _des_crypt_test.create_backend_cases(["os_crypt","builtin"])
+# create test cases for specific backends
+des_crypt_os_crypt_test = _des_crypt_test.create_backend_case("os_crypt")
+des_crypt_builtin_test = _des_crypt_test.create_backend_case("builtin")
#=============================================================================
# fshp
@@ -451,18 +534,18 @@ class fshp_test(HandlerCase):
def test_90_variant(self):
"""test variant keyword"""
handler = self.handler
- kwds = dict(salt=b('a'), rounds=1)
+ 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)
+ handler(variant=b'1', **kwds)
# aliases
handler(variant=u('sha256'), **kwds)
- handler(variant=b('sha256'), **kwds)
+ handler(variant=b'sha256', **kwds)
# rejects None
self.assertRaises(TypeError, handler, variant=None, **kwds)
@@ -671,8 +754,9 @@ class _ldap_md5_crypt_test(HandlerCase):
'{CRYPT}$1$dOHYPKoP$tnxS1T8Q6VVn3kpV8cN6o!',
]
-ldap_md5_crypt_os_crypt_test, ldap_md5_crypt_builtin_test = \
- _ldap_md5_crypt_test.create_backend_cases(["os_crypt","builtin"])
+# create test cases for specific backends
+ldap_md5_crypt_os_crypt_test =_ldap_md5_crypt_test.create_backend_case("os_crypt")
+ldap_md5_crypt_builtin_test =_ldap_md5_crypt_test.create_backend_case("builtin")
class _ldap_sha1_crypt_test(HandlerCase):
# NOTE: this isn't for testing the hash (see ldap_md5_crypt note)
@@ -691,7 +775,8 @@ class _ldap_sha1_crypt_test(HandlerCase):
def test_77_fuzz_input(self):
raise self.skipTest("unneeded")
-ldap_sha1_crypt_os_crypt_test, = _ldap_sha1_crypt_test.create_backend_cases(["os_crypt"])
+# create test cases for specific backends
+ldap_sha1_crypt_os_crypt_test = _ldap_sha1_crypt_test.create_backend_case("os_crypt")
#=============================================================================
# ldap_pbkdf2_{digest}
@@ -808,7 +893,7 @@ class _md5_crypt_test(HandlerCase):
('Compl3X AlphaNu3meric', '$1$nX1e7EeI$ljQn72ZUgt6Wxd9hfvHdV0'),
('4lpHa N|_|M3r1K W/ Cur5Es: #$%(*)(*%#', '$1$jQS7o98J$V6iTcr71CGgwW2laf17pi1'),
('test', '$1$SuMrG47N$ymvzYjr7QcEQjaK5m1PGx1'),
- (b('test'), '$1$SuMrG47N$ymvzYjr7QcEQjaK5m1PGx1'),
+ (b'test', '$1$SuMrG47N$ymvzYjr7QcEQjaK5m1PGx1'),
(u('s'), '$1$ssssssss$YgmLTApYTv12qgTwBoj8i/'),
# ensures utf-8 used for unicode
@@ -828,8 +913,9 @@ class _md5_crypt_test(HandlerCase):
("darwin", False),
]
-md5_crypt_os_crypt_test, md5_crypt_builtin_test = \
- _md5_crypt_test.create_backend_cases(["os_crypt","builtin"])
+# create test cases for specific backends
+md5_crypt_os_crypt_test = _md5_crypt_test.create_backend_case("os_crypt")
+md5_crypt_builtin_test = _md5_crypt_test.create_backend_case("builtin")
#=============================================================================
# msdcc 1 & 2
@@ -1011,7 +1097,7 @@ class mssql2000_test(HandlerCase):
known_malformed_hashes = [
# non-hex char -----\/
- b('0x01005B200543327G2E1BC2E7C5DF0F9EBFE486E9BEE063E8D3B332752E1BC2E7C5DF0F9EBFE486E9BEE063E8D3B3'),
+ b'0x01005B200543327G2E1BC2E7C5DF0F9EBFE486E9BEE063E8D3B332752E1BC2E7C5DF0F9EBFE486E9BEE063E8D3B3',
u('0x01005B200543327G2E1BC2E7C5DF0F9EBFE486E9BEE063E8D3B332752E1BC2E7C5DF0F9EBFE486E9BEE063E8D3B3'),
]
@@ -1188,10 +1274,10 @@ class nthash_test(HandlerCase):
('tigger', 'b7e0ea9fbffcf6dd83086e905089effd'),
# utf-8
- (b('\xC3\xBC'), '8bd6e4fb88e01009818749c5443ea712'),
- (b('\xC3\xBC\xC3\xBC'), 'cc1260adb6985ca749f150c7e0b22063'),
- (b('\xE2\x82\xAC'), '030926b781938db4365d46adc7cfbcb8'),
- (b('\xE2\x82\xAC\xE2\x82\xAC'),'682467b963bb4e61943e170a04f7db46'),
+ (b'\xC3\xBC', '8bd6e4fb88e01009818749c5443ea712'),
+ (b'\xC3\xBC\xC3\xBC', 'cc1260adb6985ca749f150c7e0b22063'),
+ (b'\xE2\x82\xAC', '030926b781938db4365d46adc7cfbcb8'),
+ (b'\xE2\x82\xAC\xE2\x82\xAC','682467b963bb4e61943e170a04f7db46'),
#
# custom
@@ -1209,7 +1295,7 @@ class bsd_nthash_test(HandlerCase):
known_correct_hashes = [
('passphrase', '$3$$7f8fe03093cc84b267b109625f6bbf4b'),
- (b('\xC3\xBC'), '$3$$8bd6e4fb88e01009818749c5443ea712'),
+ (b'\xC3\xBC', '$3$$8bd6e4fb88e01009818749c5443ea712'),
]
known_unidentified_hashes = [
@@ -1576,7 +1662,7 @@ class scram_test(HandlerCase):
def test_90_algs(self):
"""test parsing of 'algs' setting"""
- defaults = dict(salt=b('A')*10, rounds=1000)
+ defaults = dict(salt=b'A'*10, rounds=1000)
def parse(algs, **kwds):
for k in defaults:
kwds.setdefault(k, defaults[k])
@@ -1602,7 +1688,7 @@ class scram_test(HandlerCase):
# alg & checksum mutually exclusive.
self.assertRaises(RuntimeError, parse, ['sha-1'],
- checksum={"sha-1": b("\x00"*20)})
+ checksum={"sha-1": b"\x00"*20})
def test_90_checksums(self):
"""test internal parsing of 'checksum' keyword"""
@@ -1612,7 +1698,7 @@ class scram_test(HandlerCase):
# check sha-1 is required
self.assertRaises(ValueError, self.handler, use_defaults=True,
- checksum={'sha-256': b('X')*32})
+ checksum={'sha-256': b'X'*32})
# XXX: anything else that's not tested by the other code already?
@@ -1622,10 +1708,10 @@ class scram_test(HandlerCase):
# return appropriate value or throw KeyError
h = "$scram$10$AAAAAA$sha-1=AQ,bbb=Ag,ccc=Aw"
- s = b('\x00')*4
- self.assertEqual(edi(h,"SHA1"), (s,10, b('\x01')))
- self.assertEqual(edi(h,"bbb"), (s,10, b('\x02')))
- self.assertEqual(edi(h,"ccc"), (s,10, b('\x03')))
+ s = b'\x00'*4
+ self.assertEqual(edi(h,"SHA1"), (s,10, b'\x01'))
+ self.assertEqual(edi(h,"bbb"), (s,10, b'\x02'))
+ self.assertEqual(edi(h,"ccc"), (s,10, b'\x03'))
self.assertRaises(KeyError, edi, h, "ddd")
# config strings should cause value error.
@@ -1659,16 +1745,16 @@ class scram_test(HandlerCase):
hash = self.handler.derive_digest
# check various encodings of password work.
- s1 = b('\x01\x02\x03')
- d1 = b('\xb2\xfb\xab\x82[tNuPnI\x8aZZ\x19\x87\xcen\xe9\xd3')
+ s1 = b'\x01\x02\x03'
+ d1 = b'\xb2\xfb\xab\x82[tNuPnI\x8aZZ\x19\x87\xcen\xe9\xd3'
self.assertEqual(hash(u("\u2168"), s1, 1000, 'sha-1'), d1)
- self.assertEqual(hash(b("\xe2\x85\xa8"), s1, 1000, 'SHA-1'), d1)
+ self.assertEqual(hash(b"\xe2\x85\xa8", s1, 1000, 'SHA-1'), d1)
self.assertEqual(hash(u("IX"), s1, 1000, 'sha1'), d1)
- self.assertEqual(hash(b("IX"), s1, 1000, 'SHA1'), d1)
+ self.assertEqual(hash(b"IX", s1, 1000, 'SHA1'), d1)
# check algs
self.assertEqual(hash("IX", s1, 1000, 'md5'),
- b('3\x19\x18\xc0\x1c/\xa8\xbf\xe4\xa3\xc2\x8eM\xe8od'))
+ b'3\x19\x18\xc0\x1c/\xa8\xbf\xe4\xa3\xc2\x8eM\xe8od')
self.assertRaises(ValueError, hash, "IX", s1, 1000, 'sha-666')
# check rounds
@@ -1697,6 +1783,44 @@ class scram_test(HandlerCase):
self.assertRaises(ValueError, self.do_encrypt, u("\uFDD0"))
self.assertRaises(ValueError, self.do_verify, u("\uFDD0"), h)
+ def test_94_using_default_algs(self, param="default_algs"):
+ """using() -- 'default_algs' parameter"""
+ # create subclass
+ handler = self.handler
+ orig = list(handler.default_algs) # in case it's modified in place
+ subcls = handler.using(**{param: "sha1,md5"})
+
+ # shouldn't have changed handler
+ self.assertEqual(handler.default_algs, orig)
+
+ # should have own set
+ self.assertEqual(subcls.default_algs, ["md5", "sha-1"])
+
+ # test encrypt output
+ h1 = subcls.encrypt("dummy")
+ self.assertEqual(handler.extract_digest_algs(h1), ["md5", "sha-1"])
+
+ def test_94_using_algs(self):
+ """using() -- 'algs' parameter"""
+ self.test_94_using_default_algs(param="algs")
+
+ def test_94_needs_update_algs(self):
+ """needs_update() -- algs setting"""
+ handler1 = self.handler.using(algs="sha1,md5")
+
+ # shouldn't need update, has same algs
+ h1 = handler1.encrypt("dummy")
+ self.assertFalse(handler1.needs_update(h1))
+
+ # *currently* shouldn't need update, has superset of algs required by handler2
+ # (may change this policy)
+ handler2 = handler1.using(algs="sha1")
+ self.assertFalse(handler2.needs_update(h1))
+
+ # should need update, doesn't have all algs required by handler3
+ handler3 = handler1.using(algs="sha1,sha256")
+ self.assertTrue(handler3.needs_update(h1))
+
def test_95_context_algs(self):
"""test handling of 'algs' in context object"""
handler = self.handler
@@ -1792,8 +1916,9 @@ class _sha1_crypt_test(HandlerCase):
("freebsd|openbsd|linux|solaris|darwin", False),
]
-sha1_crypt_os_crypt_test, sha1_crypt_builtin_test = \
- _sha1_crypt_test.create_backend_cases(["os_crypt","builtin"])
+# create test cases for specific backends
+sha1_crypt_os_crypt_test = _sha1_crypt_test.create_backend_case("os_crypt")
+sha1_crypt_builtin_test = _sha1_crypt_test.create_backend_case("builtin")
#=============================================================================
# roundup
@@ -1929,8 +2054,9 @@ class _sha256_crypt_test(HandlerCase):
# solaris - depends on policy
]
-sha256_crypt_os_crypt_test, sha256_crypt_builtin_test = \
- _sha256_crypt_test.create_backend_cases(["os_crypt","builtin"])
+# create test cases for specific backends
+sha256_crypt_os_crypt_test = _sha256_crypt_test.create_backend_case("os_crypt")
+sha256_crypt_builtin_test = _sha256_crypt_test.create_backend_case("builtin")
#=============================================================================
# test sha512-crypt
@@ -2010,8 +2136,9 @@ class _sha512_crypt_test(HandlerCase):
platform_crypt_support = _sha256_crypt_test.platform_crypt_support
-sha512_crypt_os_crypt_test, sha512_crypt_builtin_test = \
- _sha512_crypt_test.create_backend_cases(["os_crypt","builtin"])
+# create test cases for specific backends
+sha512_crypt_os_crypt_test = _sha512_crypt_test.create_backend_case("os_crypt")
+sha512_crypt_builtin_test = _sha512_crypt_test.create_backend_case("builtin")
#=============================================================================
# sun md5 crypt
diff --git a/passlib/tests/test_handlers_bcrypt.py b/passlib/tests/test_handlers_bcrypt.py
index d92b1ff..abd181b 100644
--- a/passlib/tests/test_handlers_bcrypt.py
+++ b/passlib/tests/test_handlers_bcrypt.py
@@ -1,10 +1,9 @@
-"""passlib.tests.test_handlers_bcrypt - tests for passlib hash algorithms"""
+"""passlib.tests.test_handlers - tests for passlib hash algorithms"""
#=============================================================================
# imports
#=============================================================================
from __future__ import with_statement
# core
-import hashlib
import logging; log = logging.getLogger(__name__)
import os
import sys
@@ -15,7 +14,7 @@ from passlib import hash
from passlib.utils import repeat_string
from passlib.utils.compat import irange, PY3, u, get_method_function
from passlib.tests.utils import TestCase, HandlerCase, skipUnless, \
- TEST_MODE, b, catch_warnings, UserHandlerMixin, randintgauss, EncodingHandlerMixin
+ TEST_MODE, UserHandlerMixin, randintgauss, EncodingHandlerMixin
from passlib.tests.test_handlers import UPASS_WAV, UPASS_USD, UPASS_TABLE
# module
@@ -23,11 +22,12 @@ from passlib.tests.test_handlers import UPASS_WAV, UPASS_USD, UPASS_TABLE
# bcrypt
#=============================================================================
class _bcrypt_test(HandlerCase):
- "base for BCrypt test cases"
+ """base for BCrypt test cases"""
handler = hash.bcrypt
secret_size = 72
reduce_default_rounds = True
fuzz_salts_need_bcrypt_repair = True
+ has_os_crypt_fallback = False
known_correct_hashes = [
#
@@ -53,21 +53,21 @@ class _bcrypt_test(HandlerCase):
('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
'0123456789chars after 72 are ignored',
'$2a$05$abcdefghijklmnopqrstuu5s2v8.iXieOjg/.AySBTTZIIVFJeBui'),
- (b('\xa3'),
+ (b'\xa3',
'$2a$05$/OK.fbVrR/bpIqNJ5ianF.Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq'),
- (b('\xff\xa3345'),
+ (b'\xff\xa3345',
'$2a$05$/OK.fbVrR/bpIqNJ5ianF.nRht2l/HRhr6zmCp9vYUvvsqynflf9e'),
- (b('\xa3ab'),
+ (b'\xa3ab',
'$2a$05$/OK.fbVrR/bpIqNJ5ianF.6IflQkJytoRVc1yuaNtHfiuq.FRlSIS'),
- (b('\xaa')*72 + b('chars after 72 are ignored as usual'),
+ (b'\xaa'*72 + b'chars after 72 are ignored as usual',
'$2a$05$/OK.fbVrR/bpIqNJ5ianF.swQOIzjOiJ9GHEPuhEkvqrUyvWhEMx6'),
- (b('\xaa\x55'*36),
+ (b'\xaa\x55'*36,
'$2a$05$/OK.fbVrR/bpIqNJ5ianF.R9xrDjiycxMbQE2bp.vgqlYpW5wx2yy'),
- (b('\x55\xaa\xff'*24),
+ (b'\x55\xaa\xff'*24,
'$2a$05$/OK.fbVrR/bpIqNJ5ianF.9tQZzcJfm3uj2NvJ/n5xkhpqLrMpWCe'),
# keeping one of their 2y tests, because we are supporting that.
- (b('\xa3'),
+ (b'\xa3',
'$2y$05$/OK.fbVrR/bpIqNJ5ianF.Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq'),
#
@@ -186,18 +186,12 @@ class _bcrypt_test(HandlerCase):
#===================================================================
# fuzz testing
#===================================================================
- def os_supports_ident(self, hash):
+ def crypt_supports_variant(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?
- # XXX: replace this all with 'handler._lacks_2{x}_support' feature detection?
- # could even just do call to safe_crypt(ident + salt) and see what we get
- from passlib.handlers.bcrypt import IDENT_2X, IDENT_2Y
- if hash.startswith(IDENT_2X) or hash.startswith(IDENT_2Y):
- return False
- return True
+ from passlib.handlers.bcrypt import bcrypt, IDENT_2X, IDENT_2Y
+ from passlib.utils import safe_crypt
+ ident = bcrypt.from_string(hash)
+ return (safe_crypt("test", ident + "04$5BJqKfqMQvV7nS.yUguNcu") or "").startswith(ident)
def fuzz_verifier_bcrypt(self):
# test against bcrypt, if available
@@ -213,7 +207,7 @@ class _bcrypt_test(HandlerCase):
"""bcrypt"""
secret = to_bytes(secret, self.fuzz_password_encoding)
if hash.startswith(IDENT_2B):
- # bcrypt <1.1 lacks 2b support
+ # bcrypt <1.1 lacks 2B support
hash = IDENT_2A + hash[4:]
elif hash.startswith(IDENT_2):
# bcrypt doesn't support $2$ hashes; but we can fake it
@@ -234,11 +228,13 @@ class _bcrypt_test(HandlerCase):
from passlib.handlers.bcrypt import IDENT_2, IDENT_2A, IDENT_2B, IDENT_2X, IDENT_2Y, _detect_pybcrypt
from passlib.utils import to_native_str
try:
- import bcrypt
+ import bcrypt as bcrypt_mod
except ImportError:
return
if not _detect_pybcrypt():
return
+ hash.bcrypt._load_backend_pybcrypt()
+ lock = hash.bcrypt._calc_lock # reuse threadlock workaround for pybcrypt 0.2
def check_pybcrypt(secret, hash):
"""pybcrypt"""
secret = to_native_str(secret, self.fuzz_password_encoding)
@@ -247,13 +243,17 @@ class _bcrypt_test(HandlerCase):
if hash.startswith((IDENT_2B, IDENT_2Y)):
hash = IDENT_2A + hash[4:]
try:
- return bcrypt.hashpw(secret, hash) == hash
+ if lock:
+ with lock:
+ return bcrypt_mod.hashpw(secret, hash) == hash
+ else:
+ return bcrypt_mod.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
+ # test against bcryptor if available
from passlib.handlers.bcrypt import IDENT_2, IDENT_2A, IDENT_2Y, IDENT_2B
from passlib.utils import to_native_str
try:
@@ -363,29 +363,27 @@ class _bcrypt_test(HandlerCase):
with self.assertWarningList([]):
self.assertEqual(bcrypt.genhash(pwd, good), good)
- #
- # and that verify() works good & bad
- #
- with self.assertWarningList([corr_desc]):
- self.assertTrue(bcrypt.verify(pwd, bad))
- with self.assertWarningList([]):
- self.assertTrue(bcrypt.verify(pwd, good))
+ # make sure verify() works correctly with good & bad hashes
+ with self.assertWarningList([corr_desc]):
+ self.assertTrue(bcrypt.verify(pwd, bad))
+ with self.assertWarningList([]):
+ self.assertTrue(bcrypt.verify(pwd, good))
- #
- # test normhash cleans things up correctly
- #
- for pwd, bad, good in samples:
+ # make sure normhash() corrects bad hashes, leaves good unchanged
with self.assertWarningList([corr_desc]):
self.assertEqual(bcrypt.normhash(bad), good)
with self.assertWarningList([]):
self.assertEqual(bcrypt.normhash(good), good)
- self.assertEqual(bcrypt.normhash("$md5$abc"), "$md5$abc")
-hash.bcrypt._no_backends_msg() # call this for coverage purposes
+ # make sure normhash() leaves non-bcrypt hashes alone
+ self.assertEqual(bcrypt.normhash("$md5$abc"), "$md5$abc")
# create test cases for specific backends
-bcrypt_bcrypt_test, bcrypt_pybcrypt_test, bcrypt_bcryptor_test, bcrypt_os_crypt_test, bcrypt_builtin_test = \
- _bcrypt_test.create_backend_cases(["bcrypt", "pybcrypt", "bcryptor", "os_crypt", "builtin"])
+bcrypt_bcrypt_test = _bcrypt_test.create_backend_case("bcrypt")
+bcrypt_pybcrypt_test = _bcrypt_test.create_backend_case("pybcrypt")
+bcrypt_bcryptor_test = _bcrypt_test.create_backend_case("bcryptor")
+bcrypt_os_crypt_test = _bcrypt_test.create_backend_case("os_crypt")
+bcrypt_builtin_test = _bcrypt_test.create_backend_case("builtin")
#=============================================================================
# bcrypt
@@ -396,7 +394,8 @@ class _bcrypt_sha256_test(HandlerCase):
reduce_default_rounds = True
forbidden_characters = None
fuzz_salts_need_bcrypt_repair = True
- fallback_os_crypt_handler = hash.bcrypt
+ alt_safe_crypt_handler = hash.bcrypt
+ has_os_crypt_fallback = True
known_correct_hashes = [
#
@@ -492,8 +491,11 @@ class _bcrypt_sha256_test(HandlerCase):
return randintgauss(5, 8, 6, 1)
# create test cases for specific backends
-bcrypt_sha256_bcrypt_test, bcrypt_sha256_pybcrypt_test, bcrypt_sha256_bcryptor_test, bcrypt_sha256_os_crypt_test, bcrypt_sha256_builtin_test = \
- _bcrypt_sha256_test.create_backend_cases(["bcrypt", "pybcrypt", "bcryptor", "os_crypt", "builtin"])
+bcrypt_sha256_bcrypt_test = _bcrypt_sha256_test.create_backend_case("bcrypt")
+bcrypt_sha256_pybcrypt_test = _bcrypt_sha256_test.create_backend_case("pybcrypt")
+bcrypt_sha256_bcryptor_test = _bcrypt_sha256_test.create_backend_case("bcryptor")
+bcrypt_sha256_os_crypt_test = _bcrypt_sha256_test.create_backend_case("os_crypt")
+bcrypt_sha256_builtin_test = _bcrypt_sha256_test.create_backend_case("builtin")
#=============================================================================
# eof
diff --git a/passlib/tests/test_handlers_django.py b/passlib/tests/test_handlers_django.py
index 248be3a..4d91b72 100644
--- a/passlib/tests/test_handlers_django.py
+++ b/passlib/tests/test_handlers_django.py
@@ -4,7 +4,6 @@
#=============================================================================
from __future__ import with_statement
# core
-import hashlib
import logging; log = logging.getLogger(__name__)
import os
import warnings
@@ -14,7 +13,7 @@ from passlib import hash
from passlib.utils import repeat_string
from passlib.utils.compat import irange, PY3, u, get_method_function
from passlib.tests.utils import TestCase, HandlerCase, skipUnless, \
- TEST_MODE, b, catch_warnings, UserHandlerMixin, randintgauss, EncodingHandlerMixin
+ TEST_MODE, UserHandlerMixin, randintgauss, EncodingHandlerMixin
from passlib.tests.test_handlers import UPASS_WAV, UPASS_USD, UPASS_TABLE
# module
@@ -31,6 +30,7 @@ def vstr(version):
class _DjangoHelper(object):
# NOTE: not testing against Django < 1.0 since it doesn't support
# most of these hash formats.
+ __unittest_skip = True
# flag that hash wasn't added until specified version
min_django_version = ()
@@ -44,11 +44,9 @@ class _DjangoHelper(object):
from django.contrib.auth.models import check_password
def verify_django(secret, hash):
"""django/check_password"""
- if (1,4) <= DJANGO_VERSION < (1,6) and not secret:
- return "skip"
if self.handler.name == "django_bcrypt" and hash.startswith("bcrypt$$2y$"):
hash = hash.replace("$$2y$", "$$2a$")
- if DJANGO_VERSION >= (1,5) and self.django_has_encoding_glitch and isinstance(secret, bytes):
+ if self.django_has_encoding_glitch and isinstance(secret, bytes):
# e.g. unsalted_md5 on 1.5 and higher try to combine
# salt + password before encoding to bytes, leading to ascii error.
# this works around that issue.
@@ -66,11 +64,6 @@ class _DjangoHelper(object):
from django.contrib.auth.models import check_password
assert self.known_correct_hashes
for secret, hash in self.iter_known_hashes():
- if (1,4) <= DJANGO_VERSION < (1,6) and not secret:
- # django 1.4-1.5 rejects empty passwords
- self.assertFalse(check_password(secret, hash),
- "empty string should not have verified")
- continue
self.assertTrue(check_password(secret, hash),
"secret=%r hash=%r failed to verify" %
(secret, hash))
@@ -95,7 +88,7 @@ class _DjangoHelper(object):
secret, other = self.get_fuzz_password_pair()
if not secret: # django 1.4 rejects empty passwords.
continue
- if DJANGO_VERSION >= (1,5) and self.django_has_encoding_glitch and isinstance(secret, bytes):
+ if self.django_has_encoding_glitch and isinstance(secret, bytes):
# e.g. unsalted_md5 on 1.5 and higher try to combine
# salt + password before encoding to bytes, leading to ascii error.
# this works around that issue.
@@ -196,10 +189,9 @@ class django_salted_md5_test(HandlerCase, _DjangoHelper):
# looks to be fixed in a future release -- https://code.djangoproject.com/ticket/18144
# for now, we avoid salt_size==0 under 1.4
handler = self.handler
- from passlib.tests.test_ext_django import has_django14
default = handler.default_salt_size
assert handler.min_salt_size == 0
- lower = 1 if has_django14 else 0
+ lower = 1
upper = handler.max_salt_size or default*4
return randintgauss(lower, upper, default, default*.5)
@@ -265,6 +257,7 @@ class django_pbkdf2_sha1_test(HandlerCase, _DjangoHelper):
'pbkdf2_sha1$10000$KZKWwvqb8BfL$rw5pWsxJEU4JrZAQhHTCO+u0f5Y='),
]
+@skipUnless(hash.bcrypt.has_backend(), "no bcrypt backends available")
class django_bcrypt_test(HandlerCase, _DjangoHelper):
"""test django_bcrypt"""
handler = hash.django_bcrypt
@@ -299,9 +292,7 @@ class django_bcrypt_test(HandlerCase, _DjangoHelper):
# omit multi-ident tests, only $2a$ counts for this class
return None
-django_bcrypt_test = skipUnless(hash.bcrypt.has_backend(),
- "no bcrypt backends available")(django_bcrypt_test)
-
+@skipUnless(hash.bcrypt.has_backend(), "no bcrypt backends available")
class django_bcrypt_sha256_test(HandlerCase, _DjangoHelper):
"""test django_bcrypt_sha256"""
handler = hash.django_bcrypt_sha256
@@ -334,15 +325,6 @@ class django_bcrypt_sha256_test(HandlerCase, _DjangoHelper):
'bcrypt_sha256$xyz$2a$06$/3OeRpbOf8/l6nPPRdZPp.nRiyYqPobEZGdNRBWihQhiFDh1ws1tu',
]
- def test_30_HasManyIdents(self):
- raise self.skipTest("multiple idents not supported")
-
- def test_30_HasOneIdent(self):
- # forbidding ident keyword, django doesn't support configuring this
- handler = self.handler
- handler(use_defaults=True)
- self.assertRaises(TypeError, handler, ident="$2a$", use_defaults=True)
-
# NOTE: the following have been cloned from _bcrypt_test()
def populate_settings(self, kwds):
@@ -358,9 +340,6 @@ class django_bcrypt_sha256_test(HandlerCase, _DjangoHelper):
# omit multi-ident tests, only $2a$ counts for this class
return None
-django_bcrypt_sha256_test = skipUnless(hash.bcrypt.has_backend(),
- "no bcrypt backends available")(django_bcrypt_sha256_test)
-
#=============================================================================
# eof
#=============================================================================
diff --git a/passlib/tests/test_hosts.py b/passlib/tests/test_hosts.py
index c1572ae..cbf93ab 100644
--- a/passlib/tests/test_hosts.py
+++ b/passlib/tests/test_hosts.py
@@ -5,7 +5,6 @@
from __future__ import with_statement
# core
import logging; log = logging.getLogger(__name__)
-import warnings
# site
# pkg
from passlib import hosts, hash as hashmod
diff --git a/passlib/tests/test_pwd.py b/passlib/tests/test_pwd.py
new file mode 100644
index 0000000..dbb8a9a
--- /dev/null
+++ b/passlib/tests/test_pwd.py
@@ -0,0 +1,101 @@
+"""passlib.tests -- tests for passlib.pwd"""
+#=============================================================================
+# imports
+#=============================================================================
+# core
+import logging; log = logging.getLogger(__name__)
+# site
+# pkg
+from passlib.tests.utils import TestCase
+# local
+__all__ = [
+ "UtilsTest",
+ "GenerateTest",
+ "StrengthTest",
+]
+
+#=============================================================================
+#
+#=============================================================================
+class UtilsTest(TestCase):
+ """test internal utilities"""
+ descriptionPrefix = "passlib.pwd"
+
+ def test_average_entropy(self):
+ "_average_entropy()"
+ from passlib.pwd import _average_entropy
+
+ self.assertEqual(_average_entropy(""), 0)
+ self.assertEqual(_average_entropy("", True), 0)
+
+ self.assertEqual(_average_entropy("a"*8), 0)
+ self.assertEqual(_average_entropy("a"*8, True), 0)
+
+ self.assertEqual(_average_entropy("ab"), 1)
+ self.assertEqual(_average_entropy("ab"*8), 1)
+ self.assertEqual(_average_entropy("ab", True), 2)
+ self.assertEqual(_average_entropy("ab"*8, True), 16)
+
+ self.assertEqual(_average_entropy("abcd"), 2)
+ self.assertEqual(_average_entropy("abcd"*8), 2)
+ self.assertAlmostEqual(_average_entropy("abcdaaaa"), 1.5488, delta=4)
+ self.assertEqual(_average_entropy("abcd", True), 8)
+ self.assertEqual(_average_entropy("abcd"*8, True), 64)
+ self.assertAlmostEqual(_average_entropy("abcdaaaa", True), 12.3904, delta=4)
+
+#=============================================================================
+# generation
+#=============================================================================
+class GenerateTest(TestCase):
+ """test generation routines"""
+ descriptionPrefix = "passlib.pwd"
+
+ def test_PhraseGenerator(self):
+ """PhraseGenerator()"""
+ from passlib.pwd import PhraseGenerator
+
+ # test wordset can be any iterable
+ # NOTE: there are 3**3=27 possible combinations,
+ # but internal code rejects 'aaa' 'bbb' 'ccc', leaving only 24
+ results = PhraseGenerator(size=3, wordset=set("abc"))(5000)
+ self.assertEqual(len(set(results)), 24)
+
+#=============================================================================
+# strength
+#=============================================================================
+class StrengthTest(TestCase):
+ """test strength measurements"""
+ descriptionPrefix = "passlib.pwd"
+
+ reference = [
+ # (password, classify() output)
+
+ # "weak"
+ ("", 0),
+ ("0"*8, 0),
+ ("0"*48, 0),
+ ("1001"*2, 0),
+ ("123", 0),
+ ("123"*2, 0),
+ ("1234", 0),
+
+ # "somewhat weak"
+ ("12345", 1),
+ ("1234"*2, 1),
+ ("secret", 1),
+
+ # "not weak"
+ ("reallysecret", 2),
+ ("12345"*2, 2),
+ ("Eer6aiya", 2),
+ ]
+
+ def test_classify(self):
+ """classify()"""
+ from passlib.pwd import classify
+ for secret, result in self.reference:
+ self.assertEqual(classify(secret), result, "classify(%r):" % secret)
+
+#=============================================================================
+# eof
+#=============================================================================
diff --git a/passlib/tests/test_registry.py b/passlib/tests/test_registry.py
index cef255b..93573ae 100644
--- a/passlib/tests/test_registry.py
+++ b/passlib/tests/test_registry.py
@@ -4,10 +4,7 @@
#=============================================================================
from __future__ import with_statement
# core
-import hashlib
from logging import getLogger
-import os
-import time
import warnings
import sys
# site
@@ -16,7 +13,7 @@ from passlib import hash, registry, exc
from passlib.registry import register_crypt_handler, register_crypt_handler_path, \
get_crypt_handler, list_crypt_handlers, _unload_handler_name as unload_handler_name
import passlib.utils.handlers as uh
-from passlib.tests.utils import TestCase, catch_warnings
+from passlib.tests.utils import TestCase
# module
log = getLogger(__name__)
@@ -130,7 +127,7 @@ class RegistryTest(TestCase):
# TODO: check lazy load which calls register_crypt_handler (warning should be issued)
sys.modules.pop("passlib.tests._test_bad_register", None)
register_crypt_handler_path("dummy_bad", "passlib.tests._test_bad_register")
- with catch_warnings():
+ with warnings.catch_warnings():
warnings.filterwarnings("ignore", "xxxxxxxxxx", DeprecationWarning)
h = get_crypt_handler("dummy_bad")
from passlib.tests import _test_bad_register as tbr
@@ -181,7 +178,7 @@ class RegistryTest(TestCase):
register_crypt_handler(dummy_1)
self.assertIs(get_crypt_handler("dummy_1"), dummy_1)
- with catch_warnings():
+ with warnings.catch_warnings():
warnings.filterwarnings("ignore", "handler names should be lower-case, and use underscores instead of hyphens:.*", UserWarning)
# already loaded handler, using incorrect name
diff --git a/passlib/tests/test_totp.py b/passlib/tests/test_totp.py
new file mode 100644
index 0000000..0b8647d
--- /dev/null
+++ b/passlib/tests/test_totp.py
@@ -0,0 +1,2115 @@
+"""passlib.tests -- test passlib.totp"""
+#=============================================================================
+# imports
+#=============================================================================
+from __future__ import unicode_literals
+# core
+from passlib.utils.compat import PY3
+import base64
+import datetime
+import logging; log = logging.getLogger(__name__)
+import random
+import sys
+import time as _time
+# site
+# pkg
+from passlib import exc
+from passlib.utils import to_bytes, to_unicode
+from passlib.utils.pbkdf2 import _clear_caches
+from passlib.utils.compat import unicode, u
+from passlib.tests.utils import TestCase
+# local
+__all__ = [
+ "EngineTest",
+]
+
+#=============================================================================
+# helpers
+#=============================================================================
+
+# XXX: python 3 changed what error base64.b16decode() throws, from TypeError to base64.Error().
+# it wasn't until 3.3 that base32decode() also got changed.
+# really should normalize this in the code to a single BinaryDecodeError,
+# predicting this cross-version is getting unmanagable.
+Base32DecodeError = Base16DecodeError = TypeError
+if sys.version_info >= (3,0):
+ from binascii import Error as Base16DecodeError
+if sys.version_info >= (3,3):
+ from binascii import Error as Base32DecodeError
+
+PASS1 = "abcdef"
+PASS2 = b"\x00\xFF"
+KEY1 = '4AOGGDBBQSYHNTUZ'
+KEY1_RAW = b'\xe0\x1cc\x0c!\x84\xb0v\xce\x99'
+KEY2_RAW = b'\xee]\xcb9\x870\x06 D\xc8y/\xa54&\xe4\x9c\x13\xc2\x18'
+KEY3 = 'S3JDVB7QD2R7JPXX' # used in docstrings
+KEY4 = 'JBSWY3DPEHPK3PXP' # from google keyuri spec
+
+# NOTE: for randtime() below,
+# * want at least 7 bits on fractional side, to test fractional times to at least 0.01s precision
+# * want at least 32 bits on integer side, to test for 32-bit epoch issues.
+# most systems *should* have 53 bit mantissa, leaving plenty of room on both ends,
+# so using (1<<37) as scale, to allocate 16 bits on fractional side, but generate reasonable # of > 1<<32 times.
+# sanity check that we're above 44 ensures minimum requirements (44 - 37 int = 7 frac)
+assert sys.float_info.radix == 2, "unexpected float_info.radix"
+assert sys.float_info.mant_dig >= 44, "double precision unexpectedly small"
+
+def randtime():
+ """return random epoch time"""
+ return random.random() * (1<<37)
+
+def randcounter():
+ """return random counter"""
+ return random.randint(0, (1 << 32) - 1)
+
+#=============================================================================
+# util tests
+#=============================================================================
+
+class UtilsTest(TestCase):
+ descriptionPrefix = "passlib.totp"
+
+ #=============================================================================
+ # encrypt_key() & decrypt_key() helpers
+ #=============================================================================
+ def test_decrypt_key(self):
+ """decrypt_key()"""
+ from passlib.totp import decrypt_key
+
+ # reference
+ CIPHER1 = '1-C-EISCJBCQVL2V4C7B-KTTAWJP2RT4MYGWR'
+ self.assertEqual(decrypt_key(CIPHER1, PASS1), KEY1_RAW)
+
+ # base32, should be case insensitive
+ self.assertEqual(decrypt_key(CIPHER1.lower(), PASS1), KEY1_RAW)
+
+ # different salt
+ CIPHER1b = '1-C-IHEFSS5J2UNGG3BN-UIIN2VVHHNF6ZM4L'
+ self.assertEqual(decrypt_key(CIPHER1b, PASS1), KEY1_RAW)
+
+ # different sized key, password, and cost
+ CIPHER2 = '1-8-5HOZXE2SVJ2Q5QPY-ZI2WYDXLIMTPU5UIMFSJJOEPJLSI2Q6Q'
+ self.assertEqual(decrypt_key(CIPHER2, PASS2), KEY2_RAW)
+
+ # wrong password should silently result in wrong key
+ other = decrypt_key(CIPHER1, PASS2)
+ self.assertEqual(other, b'\x06\x88\xd2\xc6\xb0j\xa0\x1d\xc9\xa2')
+
+ # malformed strings
+ def assert_malformed(enckey):
+ self.assertRaisesRegex(ValueError, "malformed .* data", decrypt_key, enckey, PASS1)
+ assert_malformed("abc") # unrecognized string
+ assert_malformed('1-C-EISCJBCQVL2V4C7') # too few sections
+ assert_malformed('1-C-EISCJBCQVL2V4C7-KTTAWJP2RT4MYGWR-FOO') # too many sections
+ assert_malformed('1-C-EISCJBCQVL2V4C@-KTTAWJP2RT4MYGWR') # invalid char in salt
+ assert_malformed('1-C-EISCJBCQVL2V4C-KTTAWJP2RT4MYGWR') # invalid size of b32 encoded salt
+ self.assertRaisesRegex(ValueError, "unknown .* version", decrypt_key, '0' + CIPHER1[1:], PASS1) # unknown version
+
+ def test_encrypt_key(self):
+ """encrypt_key()"""
+ from passlib.totp import encrypt_key, decrypt_key
+
+ def test(key, pwd, **k):
+ result = encrypt_key(key, pwd, **k)
+ self.assertRegex(result, "^1-[A-F0-9]+-[A-Z0-9]+-[A-Z0-9]+$") # has right format
+ self.assertEqual(decrypt_key(result, pwd), key) # decrypts correctly
+ return result
+
+ # basic behavior
+ result = test(KEY1_RAW, PASS1)
+ self.assertEqual(len(result), 41) # expected size based on default salt size
+
+ # creates new salt each time
+ other = encrypt_key(KEY1_RAW, PASS1)
+ self.assertNotEqual(other, result)
+
+ # custom cost
+ result = test(KEY1_RAW, PASS1, cost=10)
+ self.assertTrue(result.startswith("1-A-"))
+
+ # larger key
+ result2 = test(KEY2_RAW, PASS1)
+ self.assertEqual(len(result2), 57) # expected size based on default salt size
+
+ # border case: empty key
+ # XXX: might want to allow this, but documenting behavior for now
+ self.assertRaises(ValueError, encrypt_key, b"", PASS1)
+
+ # border case: empty password
+ test(KEY1_RAW, "")
+
+ # border case: password as bytes
+ result = encrypt_key(KEY1_RAW, PASS2)
+ self.assertEqual(decrypt_key(result, PASS2), KEY1_RAW)
+
+ def test_encrypt_key_salt_size(self):
+ """ENCRYPT_SALT_SIZE"""
+ from passlib.totp import encrypt_key
+ from passlib import totp
+
+ self.addCleanup(setattr, totp, "ENCRYPT_SALT_SIZE", totp.ENCRYPT_SALT_SIZE)
+
+ totp.ENCRYPT_SALT_SIZE = 10
+ result = encrypt_key(KEY1_RAW, PASS1)
+
+ totp.ENCRYPT_SALT_SIZE = 30
+ result2 = encrypt_key(KEY1_RAW, PASS1)
+
+ self.assertEqual(len(result2), len(result) + (30-10) * 8/5.0)
+
+ def test_encrypt_key_cost(self):
+ """ENCRYPT_COST"""
+ from passlib.totp import encrypt_key
+ from passlib import totp
+
+ self.addCleanup(setattr, totp, "ENCRYPT_COST", totp.ENCRYPT_COST)
+
+ # time default cost
+ start = _time.clock()
+ _ = encrypt_key(KEY1_RAW, PASS1)
+ delta = _time.clock() - start
+
+ # this should take 8x as long
+ totp.ENCRYPT_COST += 3
+ start = _time.clock()
+ _ = encrypt_key(KEY1_RAW, PASS1)
+ delta2 = _time.clock() - start
+
+ self.assertAlmostEqual(delta2, delta*8, delta=(delta*8)*0.5)
+
+ #=============================================================================
+ # client offset helpers
+ #=============================================================================
+
+ # sample history used by suggest_offset() test
+ history1 = [
+ (1420563115, 0), # -25
+ (1420563140, 0), # -20
+ (1420563246, 0), # -6
+ (1420563363, -1), # -33
+ (1420563681, 0), # -21
+ (1420569854, 0), # -14
+ (1420571296, 0), # -16
+ (1420579589, 0), # -29
+ (1420580848, 0), # -28
+ (1420580989, 0), # -19
+ (1420581126, -1), # -36
+ (1420582973, 0), # -23
+ (1420583342, -1), # -32
+ ]
+
+ def test_suggest_offset(self):
+ """suggest_offset()"""
+ from passlib.totp import suggest_offset, DEFAULT_OFFSET
+
+ # test reference sample
+ history1 = self.history1
+ result1 = suggest_offset(history1, 30)
+ self.assertAlmostEqual(result1, -9, delta=10)
+
+ # translation by multiple of period should have no effect
+ for diff in range(-3, 4):
+ translate = diff * 30
+ history2 = [(time + translate, diff) for time, diff in history1]
+ self.assertEqual(suggest_offset(history2, 30), result1,
+ msg="history1 translated by %ds: " % translate)
+
+ # in general, translations shouldn't send value too far away from original
+ # (may relax this for new situations)
+ for translate in range(-30, 30):
+ history2 = [(time + translate, diff) for time, diff in history1]
+ self.assertAlmostEqual(suggest_offset(history2, 30), result1, delta=10,
+ msg="history1 translated by %ds: " % translate)
+
+ # handle 2 element history
+ self.assertAlmostEqual(suggest_offset(history1[:2]), -9, delta=10)
+
+ # handle 1 element history
+ self.assertAlmostEqual(suggest_offset(history1[:1]), -9, delta=10)
+
+ # empty history should use default
+ self.assertAlmostEqual(suggest_offset([]), DEFAULT_OFFSET)
+ self.assertAlmostEqual(suggest_offset([], default=-10), -10)
+
+ # fuzz test on random values
+ size = random.randint(0, 16)
+ elems = [ (randtime(), random.randint(-2,3)) for _ in range(size)]
+ suggest_offset(elems)
+
+ #=============================================================================
+ # eoc
+ #=============================================================================
+
+#=============================================================================
+# common code
+#=============================================================================
+
+#: used as base value for RFC test vector keys
+RFC_KEY_BYTES_20 = "12345678901234567890".encode("ascii")
+RFC_KEY_BYTES_32 = (RFC_KEY_BYTES_20*2)[:32]
+RFC_KEY_BYTES_64 = (RFC_KEY_BYTES_20*4)[:64]
+
+class _BaseOTPTest(TestCase):
+ """
+ common code shared by TotpTest & HotpTest
+ """
+ #=============================================================================
+ # class attrs
+ #=============================================================================
+
+ #: BaseOTP subclass we're testing.
+ OtpType = None
+
+ #=============================================================================
+ # setup
+ #=============================================================================
+ def setUp(self):
+ super(_BaseOTPTest, self).setUp()
+
+ # clear norm_hash_name() cache so 'unknown hash' warnings get emitted each time
+ _clear_caches()
+
+ #=============================================================================
+ # subclass utils
+ #=============================================================================
+ def randotp(self, **kwds):
+ """
+ helper which generates a random OtpType instance.
+ """
+ if "key" not in kwds:
+ kwds['new'] = True
+ kwds.setdefault("digits", random.randint(6, 10))
+ kwds.setdefault("alg", random.choice(["sha1", "sha256", "sha512"]))
+ return self.OtpType(**kwds)
+
+ def test_randotp(self):
+ """
+ internal test -- randotp()
+ """
+ otp1 = self.randotp()
+ otp2 = self.randotp()
+
+ self.assertNotEqual(otp1.key, otp2.key, "key not randomized:")
+
+ # NOTE: has (1/5)**10 odds of failure
+ for _ in range(10):
+ if otp1.digits != otp2.digits:
+ break
+ otp2 = self.randotp()
+ else:
+ self.fail("digits not randomized")
+
+ # NOTE: has (1/3)**10 odds of failure
+ for _ in range(10):
+ if otp1.alg != otp2.alg:
+ break
+ otp2 = self.randotp()
+ else:
+ self.fail("alg not randomized")
+
+ #=============================================================================
+ # constructor
+ #=============================================================================
+ def test_ctor_w_new(self):
+ """constructor -- 'new' parameter"""
+ OTP = self.OtpType
+
+ # exactly one of 'key' or 'new' is required
+ self.assertRaises(TypeError, OTP)
+ self.assertRaises(TypeError, OTP, key='4aoggdbbqsyhntuz', new=True)
+
+ # generates new key
+ otp = OTP(new=True)
+ otp2 = OTP(new=True)
+ self.assertNotEqual(otp.key, otp2.key)
+
+ def test_ctor_w_size(self):
+ """constructor -- 'size' parameter"""
+ OTP = self.OtpType
+
+ # should default to digest size, per RFC
+ self.assertEqual(len(OTP(new=True, alg="sha1").key), 20)
+ self.assertEqual(len(OTP(new=True, alg="sha256").key), 32)
+ self.assertEqual(len(OTP(new=True, alg="sha512").key), 64)
+
+ # explicit key size
+ self.assertEqual(len(OTP(new=True, size=10).key), 10)
+ self.assertEqual(len(OTP(new=True, size=16).key), 16)
+
+ # for new=True, maximum size enforced (based on alg)
+ self.assertRaises(ValueError, OTP, new=True, size=21, alg="sha1")
+
+ # for new=True, minimum size enforced
+ self.assertRaises(ValueError, OTP, new=True, size=9)
+
+ # for existing key, minimum size is only warned about
+ with self.assertWarningList([
+ dict(category=exc.PasslibSecurityWarning, message_re=".*for security purposes, secret key must be.*")
+ ]):
+ _ = OTP('0A'*9, 'hex')
+
+ def test_ctor_w_key_and_format(self):
+ """constructor -- 'key' and 'format' parameters"""
+ OTP = self.OtpType
+
+ # handle base32 encoding (the default)
+ self.assertEqual(OTP(KEY1).key, KEY1_RAW)
+
+ # .. w/ lower case
+ self.assertEqual(OTP(KEY1.lower()).key, KEY1_RAW)
+
+ # .. w/ spaces (e.g. user-entered data)
+ self.assertEqual(OTP(' 4aog gdbb qsyh ntuz ').key, KEY1_RAW)
+
+ # .. w/ invalid char
+ self.assertRaises(Base32DecodeError, OTP, 'ao!ggdbbqsyhntuz')
+
+ # handle hex encoding
+ self.assertEqual(OTP('e01c630c2184b076ce99', 'hex').key, KEY1_RAW)
+
+ # .. w/ invalid char
+ self.assertRaises(Base16DecodeError, OTP, 'X01c630c2184b076ce99', 'hex')
+
+ # handle raw bytes
+ self.assertEqual(OTP(KEY1_RAW, "raw").key, KEY1_RAW)
+
+ def test_ctor_w_alg(self):
+ """constructor -- 'alg' parameter"""
+ OTP = self.OtpType
+
+ # normalize hash names
+ self.assertEqual(OTP(KEY1, alg="SHA-256").alg, "sha256")
+ self.assertEqual(OTP(KEY1, alg="SHA256").alg, "sha256")
+
+ # invalid alg
+ with self.assertWarningList([
+ dict(category=exc.PasslibRuntimeWarning, message_re="unknown hash.*SHA-333")
+ ]):
+ self.assertRaises(ValueError, OTP, KEY1, alg="SHA-333")
+
+ def test_ctor_w_digits(self):
+ """constructor -- 'digits' parameter"""
+ OTP = self.OtpType
+ self.assertRaises(ValueError, OTP, KEY1, digits=5)
+ self.assertEqual(OTP(KEY1, digits=6).digits, 6) # min value
+ self.assertEqual(OTP(KEY1, digits=10).digits, 10) # max value
+ self.assertRaises(ValueError, OTP, KEY1, digits=11)
+
+ def test_ctor_w_label(self):
+ """constructor -- 'label' parameter"""
+ OTP = self.OtpType
+ self.assertEqual(OTP(KEY1).label, None)
+ self.assertEqual(OTP(KEY1, label="foo@bar").label, "foo@bar")
+ self.assertRaises(ValueError, OTP, KEY1, label="foo:bar")
+
+ def test_ctor_w_issuer(self):
+ """constructor -- 'issuer' parameter"""
+ OTP = self.OtpType
+ self.assertEqual(OTP(KEY1).issuer, None)
+ self.assertEqual(OTP(KEY1, issuer="foo.com").issuer, "foo.com")
+ self.assertRaises(ValueError, OTP, KEY1, issuer="foo.com:bar")
+
+ # NOTE: 'dirty' is internal parameter,
+ # tested via .generate_next(), .verify_next(),
+ # and to_string() / from_string()
+
+ #=============================================================================
+ # internal helpers
+ #=============================================================================
+
+ def test_normalize_token(self):
+ """normalize_token()"""
+ otp = self.randotp(digits=7)
+
+ self.assertEqual(otp.normalize_token('1234567'), '1234567')
+ self.assertEqual(otp.normalize_token(b'1234567'), '1234567')
+
+ self.assertEqual(otp.normalize_token(1234567), '1234567')
+ self.assertEqual(otp.normalize_token(234567), '0234567')
+
+ self.assertRaises(TypeError, otp.normalize_token, 1234567.0)
+ self.assertRaises(TypeError, otp.normalize_token, None)
+
+ self.assertRaises(ValueError, otp.normalize_token, '123456')
+ self.assertRaises(ValueError, otp.normalize_token, '01234567')
+
+ #=============================================================================
+ # key attrs
+ #=============================================================================
+
+ def test_key_attrs(self):
+ """pretty_key() and .key attributes"""
+ OTP = self.OtpType
+
+ # test key attrs
+ otp = OTP(KEY1_RAW, "raw")
+ self.assertEqual(otp.key, KEY1_RAW)
+ self.assertEqual(otp.hex_key, 'e01c630c2184b076ce99')
+ self.assertEqual(otp.base32_key, KEY1)
+
+ # test pretty_key()
+ self.assertEqual(otp.pretty_key(), '4AOG-GDBB-QSYH-NTUZ')
+ self.assertEqual(otp.pretty_key(sep=" "), '4AOG GDBB QSYH NTUZ')
+ self.assertEqual(otp.pretty_key(sep=False), KEY1)
+ self.assertEqual(otp.pretty_key(format="hex"), 'e01c-630c-2184-b076-ce99')
+
+ # quick fuzz test: make attr access works for random key & random size
+ otp = OTP(new=True, size=random.randint(10, 20))
+ _ = otp.hex_key
+ _ = otp.base32_key
+ _ = otp.pretty_key()
+
+ #=============================================================================
+ # eoc
+ #=============================================================================
+
+#=============================================================================
+# TOTP
+#=============================================================================
+from passlib.totp import TOTP
+
+class TotpTest(_BaseOTPTest):
+ #=============================================================================
+ # class attrs
+ #=============================================================================
+ descriptionPrefix = "passlib.totp.TOTP"
+ OtpType = TOTP
+
+ #=============================================================================
+ # test vectors
+ #=============================================================================
+
+ #: default options used by test vectors (unless otherwise stated)
+ vector_defaults = dict(format="base32", alg="sha1", period=30, digits=8)
+
+ #: various TOTP test vectors,
+ #: each element in list has format [options, (time, token <, int(expires)>), ...]
+ vectors = [
+
+ #-------------------------------------------------------------------------
+ # passlib test vectors
+ #-------------------------------------------------------------------------
+
+ # 10 byte key, 6 digits
+ [dict(key="ACDEFGHJKL234567", digits=6),
+ # test fencepost to make sure we're rounding right
+ (1412873399, '221105'), # == 29 mod 30
+ (1412873400, '178491'), # == 0 mod 30
+ (1412873401, '178491'), # == 1 mod 30
+ (1412873429, '178491'), # == 29 mod 30
+ (1412873430, '915114'), # == 0 mod 30
+ ],
+
+ # 10 byte key, 8 digits
+ [dict(key="ACDEFGHJKL234567", digits=8),
+ # should be same as 6 digits (above), but w/ 2 more digits on left side of token.
+ (1412873399, '20221105'), # == 29 mod 30
+ (1412873400, '86178491'), # == 0 mod 30
+ (1412873401, '86178491'), # == 1 mod 30
+ (1412873429, '86178491'), # == 29 mod 30
+ (1412873430, '03915114'), # == 0 mod 30
+ ],
+
+ # sanity check on key used in docstrings
+ [dict(key="S3JD-VB7Q-D2R7-JPXX", digits=6),
+ (1419622709, '000492'),
+ (1419622739, '897212'),
+ ],
+
+ #-------------------------------------------------------------------------
+ # reference vectors taken from http://tools.ietf.org/html/rfc6238, appendix B
+ # NOTE: while appendix B states same key used for all tests, the reference
+ # code in the appendix repeats the key up to the alg's block size,
+ # and uses *that* as the secret... so that's what we're doing here.
+ #-------------------------------------------------------------------------
+
+ # sha1 test vectors
+ [dict(key=RFC_KEY_BYTES_20, format="raw", alg="sha1"),
+ (59, '94287082'),
+ (1111111109, '07081804'),
+ (1111111111, '14050471'),
+ (1234567890, '89005924'),
+ (2000000000, '69279037'),
+ (20000000000, '65353130'),
+ ],
+
+ # sha256 test vectors
+ [dict(key=RFC_KEY_BYTES_32, format="raw", alg="sha256"),
+ (59, '46119246'),
+ (1111111109, '68084774'),
+ (1111111111, '67062674'),
+ (1234567890, '91819424'),
+ (2000000000, '90698825'),
+ (20000000000, '77737706'),
+ ],
+
+ # sha512 test vectors
+ [dict(key=RFC_KEY_BYTES_64, format="raw", alg="sha512"),
+ (59, '90693936'),
+ (1111111109, '25091201'),
+ (1111111111, '99943326'),
+ (1234567890, '93441116'),
+ (2000000000, '38618901'),
+ (20000000000, '47863826'),
+ ],
+
+ #-------------------------------------------------------------------------
+ # other test vectors
+ #-------------------------------------------------------------------------
+
+ # generated at http://blog.tinisles.com/2011/10/google-authenticator-one-time-password-algorithm-in-javascript
+ [dict(key="JBSWY3DPEHPK3PXP", digits=6), (1409192430, '727248'), (1419890990, '122419')],
+ [dict(key="JBSWY3DPEHPK3PXP", digits=9, period=41), (1419891152, '662331049')],
+
+ # found in https://github.com/eloquent/otis/blob/develop/test/suite/Totp/Value/TotpValueGeneratorTest.php, line 45
+ [dict(key=RFC_KEY_BYTES_20, format="raw", period=60), (1111111111, '19360094')],
+ [dict(key=RFC_KEY_BYTES_32, format="raw", alg="sha256", period=60), (1111111111, '40857319')],
+ [dict(key=RFC_KEY_BYTES_64, format="raw", alg="sha512", period=60), (1111111111, '37023009')],
+
+ ]
+
+ def iter_test_vectors(self):
+ """
+ helper to iterate over test vectors.
+ yields ``(totp, time, token, expires, prefix)`` tuples.
+ """
+ from passlib.totp import TOTP
+ for row in self.vectors:
+ kwds = self.vector_defaults.copy()
+ kwds.update(row[0])
+ for entry in row[1:]:
+ if len(entry) == 3:
+ time, token, expires = entry
+ else:
+ time, token = entry
+ expires = None
+ # NOTE: not re-using otp between calls so that stateful methods
+ # (like .verify) don't have problems.
+ log.debug("test vector: %r time=%r token=%r expires=%r", kwds, time, token, expires)
+ otp = TOTP(**kwds)
+ prefix = "alg=%r time=%r token=%r: " % (otp.alg, time, token)
+ yield otp, time, token, expires, prefix
+
+ #=============================================================================
+ # subclass utils
+ #=============================================================================
+ def randotp(self, **kwds):
+ """
+ helper which generates a random .OtpType instance for testing.
+ """
+ if "period" not in kwds:
+ kwds['period'] = random.randint(10, 120)
+ return super(TotpTest, self).randotp(**kwds)
+
+ #=============================================================================
+ # constructor
+ #=============================================================================
+
+ # NOTE: common behavior handled by _BaseOTPTest
+
+ def test_ctor_w_period(self):
+ """constructor -- 'period' parameter"""
+ OTP = self.OtpType
+
+ # default
+ self.assertEqual(OTP(KEY1).period, 30)
+
+ # explicit value
+ self.assertEqual(OTP(KEY1, period=63).period, 63)
+
+ # reject wrong type
+ self.assertRaises(TypeError, OTP, KEY1, period=1.5)
+ self.assertRaises(TypeError, OTP, KEY1, period='abc')
+
+ # reject non-positive values
+ self.assertRaises(ValueError, OTP, KEY1, period=0)
+ self.assertRaises(ValueError, OTP, KEY1, period=-1)
+
+ def test_ctor_w_now(self):
+ """constructor -- 'now' parameter"""
+
+ # NOTE: reading time w/ normalize_time() to make sure custom .now actually has effect.
+
+ # default -- time.time
+ otp = self.randotp()
+ self.assertIs(otp.now, _time.time)
+ self.assertAlmostEqual(otp.normalize_time(None), int(_time.time()))
+
+ # custom function
+ counter = [123.12]
+ def now():
+ counter[0] += 1
+ return counter[0]
+ otp = self.randotp(now=now)
+ # NOTE: TOTP() constructor currently invokes this twice, using up counter values 124 & 125
+ self.assertEqual(otp.normalize_time(None), 126)
+ self.assertEqual(otp.normalize_time(None), 127)
+
+ # require callable
+ self.assertRaises(TypeError, self.randotp, now=123)
+
+ # require returns int/float
+ msg_re = r"now\(\) function must return non-negative"
+ self.assertRaisesRegex(AssertionError, msg_re, self.randotp, now=lambda : 'abc')
+
+ # require returns non-negative value
+ self.assertRaisesRegex(AssertionError, msg_re, self.randotp, now=lambda : -1)
+
+ # NOTE: 'last_counter', '_history' are internal parameters,
+ # tested by from_string() / to_string().
+
+ #=============================================================================
+ # internal helpers
+ #=============================================================================
+
+ def test_normalize_time(self):
+ """normalize_time()"""
+ otp = self.randotp()
+
+ for _ in range(10):
+ time = randtime()
+ tint = int(time)
+
+ self.assertEqual(otp.normalize_time(time), tint)
+ self.assertEqual(otp.normalize_time(tint + 0.5), tint)
+
+ self.assertEqual(otp.normalize_time(tint), tint)
+
+ dt = datetime.datetime.utcfromtimestamp(time)
+ self.assertEqual(otp.normalize_time(dt), tint)
+
+ otp.now = lambda: time
+ self.assertEqual(otp.normalize_time(None), tint)
+
+ self.assertRaises(TypeError, otp.normalize_time, '1234')
+
+ #=============================================================================
+ # key attrs
+ #=============================================================================
+
+ # NOTE: handled by _BaseOTPTest
+
+ #=============================================================================
+ # generate()
+ #=============================================================================
+ def test_totp_token(self):
+ """generate() -- TotpToken() class"""
+ from passlib.totp import TOTP, TotpToken
+
+ # test known set of values
+ otp = TOTP('s3jdvb7qd2r7jpxx')
+ result = otp.generate(1419622739)
+ self.assertIsInstance(result, TotpToken)
+ self.assertEqual(result.token, '897212')
+ self.assertEqual(result.counter, 47320757)
+ ##self.assertEqual(result.start_time, 1419622710)
+ self.assertEqual(result.expire_time, 1419622740)
+ self.assertEqual(result, ('897212', 1419622740))
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0], '897212')
+ self.assertEqual(result[1], 1419622740)
+ self.assertRaises(IndexError, result.__getitem__, -3)
+ self.assertRaises(IndexError, result.__getitem__, 2)
+ self.assertTrue(result)
+
+ # time dependant bits...
+ otp.now = lambda : 1419622739.5
+ self.assertEqual(result.remaining, 0.5)
+ self.assertTrue(result.valid)
+
+ otp.now = lambda : 1419622741
+ self.assertEqual(result.remaining, 0)
+ self.assertFalse(result.valid)
+
+ # same time -- shouldn't return same object, but should be equal
+ result2 = otp.generate(1419622739)
+ self.assertIsNot(result2, result)
+ self.assertEqual(result2, result)
+
+ # diff time in period -- shouldn't return same object, but should be equal
+ result3 = otp.generate(1419622711)
+ self.assertIsNot(result3, result)
+ self.assertEqual(result3, result)
+
+ # shouldn't be equal
+ result4 = otp.generate(1419622999)
+ self.assertNotEqual(result4, result)
+
+ def test_generate(self):
+ """generate()"""
+ from passlib.totp import TOTP
+
+ # generate token
+ otp = TOTP(new=True)
+ time = randtime()
+ result = otp.generate(time)
+ token = result.token
+ self.assertIsInstance(token, unicode)
+ start_time = result.counter * 30
+
+ # should generate same token for next 29s
+ self.assertEqual(otp.generate(start_time + 29).token, token)
+
+ # and new one at 30s
+ self.assertNotEqual(otp.generate(start_time + 30).token, token)
+
+ # verify round-trip conversion of datetime
+ dt = datetime.datetime.utcfromtimestamp(time)
+ self.assertEqual(int(otp.normalize_time(dt)), int(time))
+
+ # handle datetime object
+ self.assertEqual(otp.generate(dt).token, token)
+
+ # omitting value should use current time
+ otp.now = lambda : time
+ self.assertEqual(otp.generate().token, token)
+
+ # reject invalid time
+ self.assertRaises(ValueError, otp.generate, -1)
+
+ def test_generate_w_reference_vectors(self, for_generate_next=False):
+ """generate() -- reference vectors"""
+ for otp, time, token, expires, prefix in self.iter_test_vectors():
+ # should output correct token for specified time
+ if for_generate_next:
+ otp.now = lambda: time
+ result = otp.generate_next()
+ else:
+ result = otp.generate(time)
+ self.assertEqual(result.token, token, msg=prefix)
+ self.assertEqual(result.counter, time // otp.period, msg=prefix)
+ if expires:
+ self.assertEqual(result.expire_time, expires)
+
+ #=============================================================================
+ # generate_next()
+ #=============================================================================
+
+ def test_generate_next(self):
+ """generate_next()"""
+ from passlib.totp import TOTP
+ from passlib.exc import PasslibSecurityWarning
+
+ # init random key & time
+ time = randtime()
+ otp = self.randotp()
+ period = otp.period
+ counter = otp._time_to_counter(time)
+ start = counter * period
+ self.assertFalse(otp.dirty)
+ otp.now = lambda: time # fix generator's time for duration of test
+
+ # generate token
+ otp.last_counter = 0
+ result = otp.generate_next()
+ token = result.token
+ self.assertEqual(result.counter, counter)
+ ##self.assertEqual(result.start_time, start)
+ self.assertEqual(otp.last_counter, counter)
+ self.assertTrue(otp.verify(token, start))
+ self.assertTrue(otp.dirty)
+
+ # should generate same token for next 29s
+ otp.last_counter = 0
+ otp.dirty = False
+ otp.now = lambda : start + period - 1
+ self.assertEqual(otp.generate_next().token, token)
+ self.assertEqual(otp.last_counter, counter)
+ self.assertTrue(otp.dirty)
+
+ # and new one at 30s
+ otp.last_counter = 0
+ otp.now = lambda : start + period
+ token2 = otp.generate_next().token
+ self.assertNotEqual(token2, token)
+ self.assertEqual(otp.last_counter, counter + 1)
+ self.assertTrue(otp.verify(token2, start + period))
+
+ # check check we issue a warning time is earlier than last counter.
+ otp.last_counter = counter + 1
+ otp.now = lambda : time
+ with self.assertWarningList([
+ dict(message_re=".*earlier than last-used time.*", category=PasslibSecurityWarning),
+ ]):
+ self.assertTrue(otp.generate_next().token)
+ self.assertEqual(otp.last_counter, counter)
+
+ def test_generate_next_w_reuse_flag(self):
+ """generate_next() -- reuse flag"""
+ from passlib.totp import TOTP
+ from passlib.exc import TokenReuseError
+ otp = TOTP(new=True)
+ token = otp.generate_next().token
+ self.assertRaises(TokenReuseError, otp.generate_next)
+ self.assertEqual(otp.generate_next(reuse=True).token, token)
+
+ def test_generate_next_w_reference_vectors(self):
+ """generate_next() -- reference vectors"""
+ self.test_generate_w_reference_vectors(for_generate_next=True)
+
+ #=============================================================================
+ # TotpMatch() -- verify()'s return value
+ #=============================================================================
+
+ def test_totp_match_w_valid_token(self):
+ """verify() -- valid TotpMatch object"""
+ from passlib.totp import TotpMatch
+
+ time = 141230981
+ token = '781501'
+ otp = TOTP(KEY3, now=lambda : time + 24 * 3600)
+ result = otp.verify(token, time)
+
+ # test type
+ self.assertIsInstance(result, TotpMatch)
+
+ # test attrs
+ self.assertTrue(result.valid)
+ self.assertAlmostEqual(result.offset, 0, delta=10) # xxx: alter this if suggest_offset() is updated?
+ self.assertEqual(result.time, time)
+ self.assertEqual(result.counter, time // 30)
+ self.assertEqual(result.counter_offset, 0)
+ self.assertEqual(result._previous_offset, 0)
+
+ # test tuple
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result, (True, result.offset))
+ self.assertRaises(IndexError, result.__getitem__, -3)
+ self.assertEqual(result[0], True)
+ self.assertEqual(result[1], result.offset)
+ self.assertRaises(IndexError, result.__getitem__, 2)
+
+ # test bool
+ self.assertTrue(result)
+
+ def test_totp_match_w_older_token(self):
+ """verify() -- valid TotpMatch object with future token"""
+ from passlib.totp import TotpMatch
+
+ time = 141230981
+ token = '781501'
+ otp = TOTP(KEY3, now=lambda : time + 24 * 3600)
+ result = otp.verify(token, time - 30)
+
+ # test type
+ self.assertIsInstance(result, TotpMatch)
+
+ # test attrs
+ self.assertTrue(result.valid)
+ self.assertAlmostEqual(result.offset, 30, delta=10) # xxx: alter this if suggest_offset() is updated?
+ self.assertEqual(result.time, time - 30)
+ self.assertEqual(result.counter, time // 30)
+ self.assertEqual(result.counter_offset, 1)
+ self.assertEqual(result._previous_offset, 0)
+
+ # test tuple
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result, (True, result.offset))
+ self.assertRaises(IndexError, result.__getitem__, -3)
+ self.assertEqual(result[0], True)
+ self.assertEqual(result[1], result.offset)
+ self.assertRaises(IndexError, result.__getitem__, 2)
+
+ # test bool
+ self.assertTrue(result)
+
+ def test_totp_match_w_new_token(self):
+ """verify() -- valid TotpMatch object with past token"""
+ from passlib.totp import TotpMatch
+
+ time = 141230981
+ token = '781501'
+ otp = TOTP(KEY3, now=lambda : time + 24 * 3600)
+ result = otp.verify(token, time + 30)
+
+ # test type
+ self.assertIsInstance(result, TotpMatch)
+
+ # test attrs
+ self.assertTrue(result.valid)
+ # NOTE: may need to alter this next line if suggest_offset() is updated ...
+ self.assertAlmostEqual(result.offset, -20, delta=10)
+ self.assertEqual(result.time, time + 30)
+ self.assertEqual(result.counter, time // 30)
+ self.assertEqual(result.counter_offset, -1)
+ self.assertEqual(result._previous_offset, 0)
+
+ # test tuple
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result, (True, result.offset))
+ self.assertRaises(IndexError, result.__getitem__, -3)
+ self.assertEqual(result[0], True)
+ self.assertEqual(result[1], result.offset)
+ self.assertRaises(IndexError, result.__getitem__, 2)
+
+ # test bool
+ self.assertTrue(result)
+
+ def test_totp_match_w_invalid_token(self):
+ """verify() -- invalid TotpMatch object"""
+ from passlib.totp import TotpMatch
+
+ time = 141230981
+ token = '781501'
+ otp = TOTP(KEY3, now=lambda : time + 24 * 3600)
+ result = otp.verify(token, time + 60)
+
+ # test type
+ self.assertIsInstance(result, TotpMatch)
+
+ # test attrs
+ self.assertFalse(result.valid)
+ self.assertEqual(result.offset, 0)
+ self.assertEqual(result.time, time + 60)
+ self.assertEqual(result.counter, 0)
+ self.assertEqual(result.counter_offset, 0)
+ self.assertEqual(result._previous_offset, 0)
+
+ # test tuple
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result, (False, result.offset))
+ self.assertRaises(IndexError, result.__getitem__, -3)
+ self.assertEqual(result[0], False)
+ self.assertEqual(result[1], result.offset)
+ self.assertRaises(IndexError, result.__getitem__, 2)
+
+ # test bool
+ self.assertFalse(result)
+
+ #=============================================================================
+ # verify()
+ #=============================================================================
+
+ def test_verify_w_window(self, for_verify_next=False):
+ """verify() -- 'time' and 'window' parameters"""
+
+ # init generator
+ time = orig_time = randtime()
+ otp = self.randotp()
+ period = otp.period
+ if for_verify_next:
+ verify = self._create_verify_next_wrapper(otp)
+ else:
+ verify = otp.verify
+ token = otp.generate(time).token
+
+ # init test helper
+ def test(correct_valid, correct_counter_offset, token, time, **kwds):
+ """helper to test verify() output"""
+ # NOTE: TotpMatch return type tested more throughly above ^^^
+ result = verify(token, time, **kwds)
+ msg = "key=%r alg=%r period=%r token=%r orig_time=%r time=%r:" % \
+ (otp.base32_key, otp.alg, otp.period, token, orig_time, time)
+ self.assertEqual(result.valid, correct_valid, msg=msg)
+ if correct_valid:
+ self.assertEqual(result.counter_offset, correct_counter_offset)
+ else:
+ self.assertEqual(result.counter_offset, 0)
+ self.assertEqual(otp.normalize_time(result.time), otp.normalize_time(time))
+
+ #-------------------------------
+ # basic validation, and 'window' parameter
+ #-------------------------------
+
+ # validate against previous counter (passes if window >= period)
+ test(False, 0, token, time - period, window=0)
+ test(True, 1, token, time - period, window=period)
+ test(True, 1, token, time - period, window=2 * period)
+
+ # validate against current counter
+ test(True, 0, token, time, window=0)
+
+ # validate against next counter (passes if window >= period)
+ test(False, 0, token, time + period, window=0)
+ test(True, -1, token, time + period, window=period)
+ test(True, -1, token, time + period, window=2 * period)
+
+ # validate against two time steps later (should never pass)
+ test(False, 0, token, time + 2 * period, window=0)
+ test(False, 0, token, time + 2 * period, window=period)
+ test(True, -2, token, time + 2 * period, window=2 * period)
+
+ # TODO: test window values that aren't multiples of period
+ # (esp ensure counter rounding works correctly)
+
+ #-------------------------------
+ # offset param
+ #-------------------------------
+
+ # TODO: test offset param
+
+ # TODO: test offset + window
+
+ #-------------------------------
+ # time normalization
+ #-------------------------------
+
+ # handle datetimes
+ dt = datetime.datetime.utcfromtimestamp(time)
+ test(True, 0, token, dt, window=0)
+
+ # reject invalid time
+ self.assertRaises(ValueError, otp.verify, token, -1)
+
+ def test_verify_w_token_normalization(self, for_verify_next=False):
+ """verify() -- token normalization"""
+ # setup test helper
+ otp = TOTP('otxl2f5cctbprpzx')
+ if for_verify_next:
+ verify = self._create_verify_next_wrapper(otp)
+ else:
+ verify = otp.verify
+ time = 1412889861
+
+ # separators / spaces should be stripped (orig token '332136')
+ self.assertTrue(verify(' 3 32-136 ', time).valid)
+
+ # ascii bytes
+ self.assertTrue(verify(b'332136', time).valid)
+
+ # too few digits
+ self.assertRaises(ValueError, verify, '12345', time)
+
+ # invalid char
+ self.assertRaises(ValueError, verify, '12345X', time)
+
+ # leading zeros count towards size
+ self.assertRaises(ValueError, verify, '0123456', time)
+
+ def test_verify_w_reference_vectors(self, for_verify_next=False):
+ """verify() -- reference vectors"""
+ for otp, time, token, expires, msg in self.iter_test_vectors():
+ # create wrapper
+ if for_verify_next:
+ verify = self._create_verify_next_wrapper(otp)
+ else:
+ verify = otp.verify
+
+ # token should verify against time
+ if for_verify_next:
+ real_offset = -divmod(time, otp.period)[1]
+ msg = "%s(with next_offset=%r, real_offset=%r):" % (msg, otp._next_offset(time),
+ real_offset)
+ result = verify(token, time)
+ self.assertTrue(result.valid, msg=msg)
+ self.assertEqual(result.counter, time // otp.period, msg=msg)
+
+ # should NOT verify against another time
+ result = verify(token, time + 100, window=0)
+ self.assertFalse(result.valid, msg=msg)
+
+ #=============================================================================
+ # verify_next()
+ #=============================================================================
+ def _create_verify_next_wrapper(self, otp):
+ """
+ returns a wrapper around verify_next()
+ which makes it's signature & return match verify(),
+ to helper out shared test code.
+ """
+ from passlib.totp import TotpMatch
+ def wrapper(token, time, **kwds):
+ # reset internal state
+ time = otp.normalize_time(time)
+ otp.last_counter = 0
+ otp._history = None
+ # fix current time
+ orig = otp.now
+ try:
+ otp.now = lambda: time
+ # run verify next w/in our sandbox
+ offset = otp._next_offset(time)
+ valid = otp.verify_next(token, **kwds)
+ finally:
+ otp.now = orig
+ # create fake TotpMatch instance to return
+ result = TotpMatch(valid, otp.last_counter, time, offset, otp.period)
+ # check that history was populated correctly
+ if valid:
+ self.assertEqual(otp._history[0][1], result.counter_offset)
+ else:
+ self.assertEqual(otp._history, None)
+ return result
+ return wrapper
+
+ def test_verify_next_w_window(self):
+ """verify_next() -- 'window' parameter"""
+ self.test_verify_w_window(for_verify_next=True)
+
+ def test_verify_next_w_token_normalization(self):
+ """verify_next() -- token normalization"""
+ self.test_verify_w_token_normalization(for_verify_next=True)
+
+ def test_verify_next_w_last_counter(self):
+ """verify_next() -- 'last_counter' and '_history' attributes"""
+ from passlib.exc import TokenReuseError
+
+ # init generator
+ otp = self.randotp()
+ period = otp.period
+
+ time = randtime()
+ result = otp.generate(time)
+ self.assertEqual(otp.last_counter, 0) # ensure generate() didn't touch it
+ token = result.token
+ counter = result.counter
+ otp.now = lambda : time # fix verify_next() time for duration of test
+
+ # verify token
+ self.assertTrue(otp.verify_next(token))
+ self.assertEqual(otp.last_counter, counter)
+
+ # test reuse policies
+ self.assertRaises(TokenReuseError, otp.verify_next, token)
+ self.assertRaises(TokenReuseError, otp.verify_next, token, reuse=False)
+ self.assertTrue(otp.verify_next(token, reuse=True))
+ self.assertEqual(otp.last_counter, counter)
+
+ # should reject older token even if within window
+ otp.last_counter = counter
+ old_token = otp.generate(time - period).token
+ self.assertFalse(otp.verify_next(old_token))
+ self.assertFalse(otp.verify_next(old_token, reuse="ignore"))
+ self.assertFalse(otp.verify_next(old_token, reuse="allow"))
+ self.assertEqual(otp.last_counter, counter)
+
+ # next token should advance .last_counter
+ otp.last_counter = counter
+ token2 = otp.generate(time + period).token
+ otp.now = lambda: time + period
+ self.assertTrue(otp.verify_next(token2))
+ self.assertEqual(otp.last_counter, counter + 1)
+
+ # TODO: test history & suggested offset for next time.
+
+ # TODO: test dirty flag behavior
+
+ def test_verify_next_w_reference_vectors(self):
+ """verify_next() -- reference vectors"""
+ self.test_verify_w_reference_vectors(for_verify_next=True)
+
+ #=============================================================================
+ # uri serialization
+ #=============================================================================
+ def test_from_uri(self):
+ """from_uri()"""
+ from passlib.totp import from_uri, TOTP
+
+ # URIs from https://code.google.com/p/google-authenticator/wiki/KeyUriFormat
+
+ #--------------------------------------------------------------------------------
+ # canonical uri
+ #--------------------------------------------------------------------------------
+ otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "issuer=Example")
+ self.assertIsInstance(otp, TOTP)
+ self.assertEqual(otp.key, b'Hello!\xde\xad\xbe\xef')
+ self.assertEqual(otp.label, "alice@google.com")
+ self.assertEqual(otp.issuer, "Example")
+ self.assertEqual(otp.alg, "sha1") # default
+ self.assertEqual(otp.period, 30) # default
+ self.assertEqual(otp.digits, 6) # default
+
+ #--------------------------------------------------------------------------------
+ # secret param
+ #--------------------------------------------------------------------------------
+
+ # secret case insensitive
+ otp = from_uri("otpauth://totp/Example:alice@google.com?secret=jbswy3dpehpk3pxp&"
+ "issuer=Example")
+ self.assertEqual(otp.key, b'Hello!\xde\xad\xbe\xef')
+
+ # missing secret
+ self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?digits=6")
+
+ # undecodable secret
+ self.assertRaises(Base32DecodeError, from_uri, "otpauth://totp/Example:alice@google.com?"
+ "secret=JBSWY3DPEHP@3PXP")
+
+ #--------------------------------------------------------------------------------
+ # label param
+ #--------------------------------------------------------------------------------
+
+ # w/ encoded space
+ otp = from_uri("otpauth://totp/Provider1:Alice%20Smith?secret=JBSWY3DPEHPK3PXP&"
+ "issuer=Provider1")
+ self.assertEqual(otp.label, "Alice Smith")
+ self.assertEqual(otp.issuer, "Provider1")
+
+ # w/ encoded space and colon
+ # (note url has leading space before 'alice') -- taken from KeyURI spec
+ otp = from_uri("otpauth://totp/Big%20Corporation%3A%20alice@bigco.com?"
+ "secret=JBSWY3DPEHPK3PXP")
+ self.assertEqual(otp.label, "alice@bigco.com")
+ self.assertEqual(otp.issuer, "Big Corporation")
+
+ #--------------------------------------------------------------------------------
+ # issuer param / prefix
+ #--------------------------------------------------------------------------------
+
+ # 'new style' issuer only
+ otp = from_uri("otpauth://totp/alice@bigco.com?secret=JBSWY3DPEHPK3PXP&issuer=Big%20Corporation")
+ self.assertEqual(otp.label, "alice@bigco.com")
+ self.assertEqual(otp.issuer, "Big Corporation")
+
+ # new-vs-old issuer mismatch
+ self.assertRaises(ValueError, TOTP.from_uri,
+ "otpauth://totp/Provider1:alice?secret=JBSWY3DPEHPK3PXP&issuer=Provider2")
+
+ #--------------------------------------------------------------------------------
+ # algorithm param
+ #--------------------------------------------------------------------------------
+
+ # custom alg
+ otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256")
+ self.assertEqual(otp.alg, "sha256")
+
+ # unknown alg
+ with self.assertWarningList([exc.PasslibRuntimeWarning]):
+ self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?"
+ "secret=JBSWY3DPEHPK3PXP&algorithm=SHA333")
+
+ #--------------------------------------------------------------------------------
+ # digit param
+ #--------------------------------------------------------------------------------
+
+ # custom digits
+ otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=8")
+ self.assertEqual(otp.digits, 8)
+
+ # digits out of range / invalid
+ self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=A")
+ self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=%20")
+ self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=15")
+
+ #--------------------------------------------------------------------------------
+ # period param
+ #--------------------------------------------------------------------------------
+
+ # custom period
+ otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&period=63")
+ self.assertEqual(otp.period, 63)
+
+ # reject period < 1
+ self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?"
+ "secret=JBSWY3DPEHPK3PXP&period=0")
+
+ self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?"
+ "secret=JBSWY3DPEHPK3PXP&period=-1")
+
+ #--------------------------------------------------------------------------------
+ # unrecognized param
+ #--------------------------------------------------------------------------------
+
+ # should issue warning, but otherwise ignore extra param
+ with self.assertWarningList([
+ dict(category=exc.PasslibRuntimeWarning, message_re="unexpected parameters encountered")
+ ]):
+ otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "foo=bar&period=63")
+ self.assertEqual(otp.base32_key, KEY4)
+ self.assertEqual(otp.period, 63)
+
+ def test_to_uri(self):
+ """to_uri()"""
+
+ #-------------------------------------------------------------------------
+ # label & issuer parameters
+ #-------------------------------------------------------------------------
+
+ # with label & issuer
+ otp = TOTP(KEY4, alg="sha1", digits=6, period=30)
+ self.assertEqual(otp.to_uri("alice@google.com", "Example Org"),
+ "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "issuer=Example%20Org")
+
+ # label is required
+ self.assertRaises(ValueError, otp.to_uri, None, "Example Org")
+
+ # with label only
+ self.assertEqual(otp.to_uri("alice@google.com"),
+ "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP")
+
+ # with default label from constructor
+ otp.label = "alice@google.com"
+ self.assertEqual(otp.to_uri(),
+ "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP")
+
+ # with default label & default issuer from constructor
+ otp.issuer = "Example Org"
+ self.assertEqual(otp.to_uri(),
+ "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP"
+ "&issuer=Example%20Org")
+
+ # reject invalid label
+ self.assertRaises(ValueError, otp.to_uri, "label:with:semicolons")
+
+ # reject invalid issue
+ self.assertRaises(ValueError, otp.to_uri, "alice@google.com", "issuer:with:semicolons")
+
+ #-------------------------------------------------------------------------
+ # algorithm parameter
+ #-------------------------------------------------------------------------
+ self.assertEqual(TOTP(KEY4, alg="sha256").to_uri("alice@google.com"),
+ "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "algorithm=SHA256")
+
+ #-------------------------------------------------------------------------
+ # digits parameter
+ #-------------------------------------------------------------------------
+ self.assertEqual(TOTP(KEY4, digits=8).to_uri("alice@google.com"),
+ "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "digits=8")
+
+ #-------------------------------------------------------------------------
+ # period parameter
+ #-------------------------------------------------------------------------
+ self.assertEqual(TOTP(KEY4, period=63).to_uri("alice@google.com"),
+ "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "period=63")
+
+ #=============================================================================
+ # json serialization
+ #=============================================================================
+
+ # TODO: from_string()
+ # with uri
+ # without needed password
+ # with needed password
+ # with bad version, decode error
+
+ # TODO: to_string()
+ # with password
+ # with custom cost
+ # with password=True
+
+ # TODO: check history, last_counter are preserved
+
+ #=============================================================================
+ # eoc
+ #=============================================================================
+
+#=============================================================================
+# HOTP
+#=============================================================================
+from passlib.totp import HOTP
+
+class HotpTest(_BaseOTPTest):
+ #=============================================================================
+ # class attrs
+ #=============================================================================
+ descriptionPrefix = "passlib.totp.HOTP"
+ OtpType = HOTP
+
+ #=============================================================================
+ # test vectors
+ #=============================================================================
+
+ #: default options used by test vectors (unless otherwise stated)
+ vector_defaults = dict(format="base32", alg="sha1")
+
+ #: various TOTP test vectors,
+ #: each element in list has format [options, (counter, token), ...]
+ vectors = [
+
+ #-------------------------------------------------------------------------
+ # reference vectors taken from http://tools.ietf.org/html/rfc4226, appendix D
+ #-------------------------------------------------------------------------
+
+ # table 2 "decimal" column
+ [dict(key=RFC_KEY_BYTES_20, format="raw", digits=10),
+ (0, '1284755224'),
+ (1, '1094287082'),
+ (2, '0137359152'),
+ (3, '1726969429'),
+ (4, '1640338314'),
+ (5, '0868254676'),
+ (6, '1918287922'),
+ (7, '0082162583'),
+ (8, '0673399871'),
+ (9, '0645520489'),
+ ],
+
+ # table 2 "HOTP" column
+ [dict(key=RFC_KEY_BYTES_20, format="raw", digits=6),
+ (0, '755224'),
+ (1, '287082'),
+ (2, '359152'),
+ (3, '969429'),
+ (4, '338314'),
+ (5, '254676'),
+ (6, '287922'),
+ (7, '162583'),
+ (8, '399871'),
+ (9, '520489'),
+ ],
+
+ #-------------------------------------------------------------------------
+ # test vectors from
+ # https://github.com/eloquent/otis/blob/develop/test/suite/Hotp/Value/HotpValueTest.php
+ #-------------------------------------------------------------------------
+
+ # sha256 variant of RFC test vectors -- 10 digit token
+ [dict(key=RFC_KEY_BYTES_20, format="raw", digits=10, alg="sha256"),
+ (0, '2074875740'),
+ (1, '1332247374'),
+ (2, '1766254785'),
+ (3, '0667496144'),
+ (4, '1625480556'),
+ (5, '0089697997'),
+ (6, '0640191609'),
+ (7, '1267579288'),
+ (8, '1883895912'),
+ (9, '0223184989'),
+ ],
+
+ # sha256 variant of RFC test vectors -- 6 digit token
+ [dict(key=RFC_KEY_BYTES_20, format="raw", digits=6, alg="sha256"),
+ (0, '875740'),
+ (1, '247374'),
+ (2, '254785'),
+ (3, '496144'),
+ (4, '480556'),
+ (5, '697997'),
+ (6, '191609'),
+ (7, '579288'),
+ (8, '895912'),
+ (9, '184989'),
+ ],
+
+ # sha512 variant of RFC test vectors -- 10 digit token
+ [dict(key=RFC_KEY_BYTES_20, format="raw", digits=10, alg="sha512"),
+ (0, '0604125165'),
+ (1, '0369342147'),
+ (2, '0671730102'),
+ (3, '0573778726'),
+ (4, '1581937510'),
+ (5, '1516848329'),
+ (6, '0836266680'),
+ (7, '0022588359'),
+ (8, '0245039399'),
+ (9, '1033643409'),
+ ],
+
+ # sha512 variant of RFC test vectors -- 6 digit token
+ [dict(key=RFC_KEY_BYTES_20, format="raw", digits=6, alg="sha512"),
+ (0, '125165'),
+ (1, '342147'),
+ (2, '730102'),
+ (3, '778726'),
+ (4, '937510'),
+ (5, '848329'),
+ (6, '266680'),
+ (7, '588359'),
+ (8, '039399'),
+ (9, '643409'),
+ ],
+
+ #-------------------------------------------------------------------------
+ # other test vectors
+ #-------------------------------------------------------------------------
+
+ # taken from example at
+ # http://stackoverflow.com/questions/8529265/google-authenticator-implementation-in-python
+ [dict(key='MZXW633PN5XW6MZX', digits=6),
+ (1, '448400'),
+ (2, '656122'),
+ (3, '457125'),
+ (4, '035022'),
+ (5, '401553'),
+ (6, '581333'),
+ (7, '016329'),
+ (8, '529359'),
+ (9, '171710'),
+ ],
+
+ # source unknown
+ [dict(key='MFRGGZDFMZTWQ2LK', digits=6),
+ (1, '765705'),
+ (2, '816065'),
+ (4, '713385'),
+ ],
+
+ ]
+
+ def iter_test_vectors(self):
+ """
+ helper to iterate over test vectors.
+ yields ``(hotp_object, counter, token, prefix)`` tuples.
+ """
+ for row in self.vectors:
+ kwds = self.vector_defaults.copy()
+ kwds.update(row[0])
+ for entry in row[1:]:
+ counter, token = entry
+ # NOTE: not re-using otp between calls so that stateful methods
+ # (like .verify) don't have problems.
+ log.debug("test vector: %r counter=%r token=%r", kwds, counter, token)
+ otp = HOTP(**kwds)
+ prefix = "reference(key=%r, alg=%r, counter=%r, token=%r): " % (otp.base32_key, otp.alg, counter, token)
+ yield otp, counter, token, prefix
+
+ #=============================================================================
+ # subclass utils
+ #=============================================================================
+ def randotp(self, **kwds):
+ """
+ helper which generates a random OtpType instance.
+ """
+ if "counter" not in kwds:
+ kwds["counter"] = randcounter()
+ return super(HotpTest, self).randotp(**kwds)
+
+ #=============================================================================
+ # constructor
+ #=============================================================================
+
+ # NOTE: common behavior handled by _BaseOTPTest
+
+ def test_ctor_w_counter(self):
+ """constructor -- 'counter' parameter"""
+
+ # default
+ otp = HOTP(KEY1)
+ self.assertEqual(otp.counter, 0)
+
+ # explicit value
+ otp = HOTP(KEY1, counter=1234)
+ self.assertEqual(otp.counter, 1234)
+
+ # reject wrong type
+ self.assertRaises(TypeError, HOTP, KEY1, counter=1.0)
+ self.assertRaises(TypeError, HOTP, KEY1, counter='abc')
+
+ # reject negative value
+ self.assertRaises(ValueError, HOTP, KEY1, counter=-1)
+
+ # NOTE: 'start' is internal parameter, tested by from_string() / to_string()
+
+ #=============================================================================
+ # generate()
+ #=============================================================================
+ def test_generate(self):
+ """generate() -- basic behavior"""
+
+ # generate token
+ counter = randcounter()
+ otp = self.randotp()
+ token = otp.generate(counter)
+ self.assertIsInstance(token, unicode)
+
+ # should generate same token
+ self.assertEqual(otp.generate(counter), token)
+
+ # and new one for other counters
+ self.assertNotEqual(otp.generate(counter-1), token)
+ self.assertNotEqual(otp.generate(counter+1), token)
+
+ # value requires
+ self.assertRaises(TypeError, otp.generate)
+
+ # reject invalid counter
+ self.assertRaises(ValueError, otp.generate, -1)
+
+ def test_generate_w_reference_vectors(self):
+ """generate() -- reference vectors"""
+ for otp, counter, token, msg in self.iter_test_vectors():
+ # should output correct token for specified counter
+ result = otp.generate(counter)
+ self.assertEqual(result, token, msg=msg)
+
+ #=============================================================================
+ # generate_next()
+ #=============================================================================
+
+ def test_generate_next(self):
+ """generate_next() -- basic behavior
+
+ .. note:: also tests 'counter' and 'dirty' attributes
+ """
+
+ # init random counter & key
+ counter = randcounter()
+ otp = self.randotp(counter=counter)
+ self.assertFalse(otp.dirty)
+
+ # generate token
+ token = otp.generate_next()
+ self.assertEqual(otp.counter, counter + 1) # should increment counter
+ self.assertTrue(otp.verify(token, counter)) # should have used .counter
+ self.assertTrue(otp.dirty)
+
+ # should generate new token and increment counter
+ token = otp.generate_next()
+ self.assertEqual(otp.counter, counter + 2) # should increment counter
+ self.assertTrue(otp.verify(token, counter + 1)) # should have used .counter
+
+ def test_generate_next_w_reference_vectors(self):
+ """generate_next() -- reference vectors"""
+ for otp, counter, token, msg in self.iter_test_vectors():
+ # should output correct token for specified counter
+ otp.counter = counter
+ result = otp.generate_next()
+ self.assertEqual(result, token, msg=msg)
+
+ #=============================================================================
+ # HotpMatch() -- verify()'s return value
+ #=============================================================================
+ def test_hotp_match_w_valid_token(self):
+ """verify() -- valid HotpMatch object"""
+ from passlib.totp import HotpMatch
+
+ otp = HOTP(KEY3)
+ counter = 41230981
+ token = '775167'
+ result = otp.verify(token, counter)
+
+ # test type
+ self.assertIsInstance(result, HotpMatch)
+
+ # test attrs
+ self.assertTrue(result.valid)
+ self.assertEqual(result.counter, counter+1)
+ self.assertEqual(result.counter_offset, 0)
+
+ # test tuple
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result, (True, counter+1))
+ self.assertRaises(IndexError, result.__getitem__, -3)
+ self.assertEqual(result[0], True)
+ self.assertEqual(result[1], counter+1)
+ self.assertRaises(IndexError, result.__getitem__, 2)
+
+ # test bool
+ self.assertTrue(result)
+
+ def test_hotp_match_w_skipped_counter(self):
+ """verify() -- valid HotpMatch object w/ skipped counter"""
+ from passlib.totp import HotpMatch
+
+ otp = HOTP(KEY3)
+ counter = 41230981
+ token = '775167'
+ result = otp.verify(token, counter-1)
+
+ # test type
+ self.assertIsInstance(result, HotpMatch)
+
+ # test attrs
+ self.assertTrue(result.valid)
+ self.assertEqual(result.counter, counter + 1)
+ self.assertEqual(result.counter_offset, 1)
+
+ # test tuple
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result, (True, counter + 1))
+ self.assertRaises(IndexError, result.__getitem__, -3)
+ self.assertEqual(result[0], True)
+ self.assertEqual(result[1], counter + 1)
+ self.assertRaises(IndexError, result.__getitem__, 2)
+
+ # test bool
+ self.assertTrue(result)
+
+ def test_hotp_match_w_invalid_token(self):
+ """verify() -- invalid HotpMatch object"""
+ from passlib.totp import HotpMatch
+
+ otp = HOTP(KEY3)
+ counter = 41230981
+ token = '775167'
+ result = otp.verify(token, counter+1)
+
+ # test type
+ self.assertIsInstance(result, HotpMatch)
+
+ # test attrs
+ self.assertFalse(result.valid)
+ self.assertEqual(result.counter, counter + 1)
+ self.assertEqual(result.counter_offset, 0)
+
+ # test tuple
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result, (False, counter + 1))
+ self.assertRaises(IndexError, result.__getitem__, -3)
+ self.assertEqual(result[0], False)
+ self.assertEqual(result[1], counter + 1)
+ self.assertRaises(IndexError, result.__getitem__, 2)
+
+ # test bool
+ self.assertFalse(result)
+
+ #=============================================================================
+ # verify()
+ #=============================================================================
+ def test_verify_w_window(self, for_verify_next=False):
+ """verify() -- 'counter' & 'window' parameters"""
+ # init generator
+ counter = randcounter()
+ otp = self.randotp()
+ if for_verify_next:
+ verify = self._create_verify_next_wrapper(otp)
+ else:
+ verify = otp.verify
+ token = otp.generate(counter)
+
+ # init test helper
+ def test(valid, counter_offset, token, counter, **kwds):
+ """helper to test verify() output"""
+ # NOTE: HotpMatch return type tested more throughly above ^^^
+ result = verify(token, counter, **kwds)
+ self.assertEqual(result.valid, valid)
+ if valid:
+ self.assertEqual(result.counter, counter + 1 + counter_offset)
+ else:
+ self.assertEqual(result.counter, counter)
+ self.assertEqual(result.counter_offset, counter_offset)
+
+ # validate against previous counter step (passes if window >= 1)
+ test(False, 0, token, counter-1, window=0)
+ test(True, 1, token, counter-1) # window=1 is default
+ test(True, 1, token, counter-1, window=2)
+
+ # validate against current counter step
+ test(True, 0, token, counter, window=0)
+
+ # validate against next counter step (should never pass)
+ test(False, 0, token, counter+1, window=0)
+ test(False, 0, token, counter+1) # window=1 is default
+ test(False, 0, token, counter+1, window=2)
+
+ def test_verify_w_token_normalization(self, for_verify_next=False):
+ """verify() -- token normalization"""
+ # setup test helper
+ otp = HOTP(KEY3)
+ if for_verify_next:
+ verify = self._create_verify_next_wrapper(otp)
+ else:
+ verify = otp.verify
+
+ # separators / spaces should be stripped (orig token '049644')
+ counter = 2889830
+ correct = (True, counter+1)
+ self.assertEqual(verify(' 0 49-644 ', counter), correct)
+
+ # ascii bytes
+ self.assertEqual(verify(b'049644', counter), correct)
+
+ # integer value (leading 0 should be implied)
+ self.assertEqual(verify(49644, counter), correct)
+
+ # too few digits
+ self.assertRaises(ValueError, verify, '12345', counter)
+
+ # invalid char
+ self.assertRaises(ValueError, verify, '12345X', counter)
+
+ # leading zeros count towards size
+ self.assertRaises(ValueError, verify, '0123456', counter)
+
+ def test_verify_w_reference_vectors(self, for_verify_next=False):
+ """verify() -- reference vectors"""
+ for otp, counter, token, msg in self.iter_test_vectors():
+ # create wrapper
+ if for_verify_next:
+ verify = self._create_verify_next_wrapper(otp)
+ else:
+ verify = otp.verify
+
+ # token should match counter *exactly*
+ result = verify(token, counter, window=0)
+ self.assertTrue(result.valid, msg=msg)
+ self.assertEqual(result.counter, counter+1, msg=msg) # NOTE: will report *next* counter valid
+ self.assertEqual(result.counter_offset, 0, msg=msg)
+
+ # should NOT verify against another counter
+ result = verify(token, counter + 100, window=0)
+ self.assertFalse(result.valid, msg=msg)
+ self.assertEqual(result.counter, counter + 100, msg=msg)
+ self.assertEqual(result.counter_offset, 0, msg=msg)
+
+ #=============================================================================
+ # verify_next()
+ #=============================================================================
+ def _create_verify_next_wrapper(self, otp):
+ """
+ returns a wrapper around verify_next()
+ which makes it's signature & return match verify(),
+ to helper out shared test code.
+ """
+ from passlib.totp import HotpMatch
+ def wrapper(token, counter=None, **kwds):
+ otp.counter = counter
+ valid = otp.verify_next(token, **kwds)
+ return HotpMatch(valid, otp.counter, otp.counter - 1 - counter if valid else 0)
+ return wrapper
+
+ def test_verify_next_w_window(self):
+ """verify_next() -- 'window' parameter"""
+ self.test_verify_w_window(for_verify_next=True)
+
+ def test_verify_next_w_token_normalization(self):
+ """verify_next() -- token normalization"""
+ self.test_verify_w_token_normalization(for_verify_next=True)
+
+ def test_verify_next_w_counter(self):
+ """verify_next() -- 'counter' and 'dirty' attributes"""
+
+ # init generator
+ counter = randcounter()
+ otp = self.randotp(counter=counter)
+ token = otp.generate(counter)
+ self.assertEqual(otp.counter, counter)
+ self.assertFalse(otp.dirty)
+
+ # verify token, should advance counter & set dirty flag
+ self.assertTrue(otp.verify_next(token))
+ self.assertEqual(otp.counter, counter + 1)
+ self.assertTrue(otp.dirty)
+
+ # reverify should reject token, leaving counter & dirty flag alone.
+ otp.counter = counter + 1
+ otp.dirty = False
+ self.assertFalse(otp.verify_next(token))
+ self.assertEqual(otp.counter, counter + 1)
+ self.assertFalse(otp.dirty)
+
+ def test_verify_next_w_reference_vectors(self):
+ """verify_next() -- reference vectors"""
+ self.test_verify_w_reference_vectors(for_verify_next=True)
+
+ #=============================================================================
+ # uri serialization
+ #=============================================================================
+
+ def test_from_uri(self):
+ """from_uri()"""
+ from passlib.totp import from_uri
+
+ # URIs adapted from https://code.google.com/p/google-authenticator/wiki/KeyUriFormat
+ # NOTE: that source doesn't give HOTP examples, so these were created
+ # by altering the TOTP example.
+
+ #--------------------------------------------------------------------------------
+ # canonical uri
+ #--------------------------------------------------------------------------------
+ otp = from_uri("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "counter=123&issuer=Example")
+ self.assertIsInstance(otp, HOTP)
+ self.assertEqual(otp.key, b'Hello!\xde\xad\xbe\xef')
+ self.assertEqual(otp.label, "alice@google.com")
+ self.assertEqual(otp.issuer, "Example")
+ self.assertEqual(otp.alg, "sha1") # implicit default
+ self.assertEqual(otp.digits, 6) # implicit default
+ self.assertEqual(otp.counter, 123)
+
+ #--------------------------------------------------------------------------------
+ # secret param
+ #--------------------------------------------------------------------------------
+
+ # secret case insensitive
+ otp = from_uri("otpauth://hotp/Example:alice@google.com?secret=jbswy3dpehpk3pxp&"
+ "counter=123&issuer=Example")
+ self.assertEqual(otp.key, b'Hello!\xde\xad\xbe\xef')
+
+ # missing secret
+ self.assertRaises(ValueError, from_uri, "otpauth://hotp/Example:alice@google.com?"
+ "counter=123")
+
+ # undecodable secret
+ self.assertRaises(Base32DecodeError, from_uri, "otpauth://hotp/Example:alice@google.com?"
+ "secret=JBSWY3DPEHP@3PXP&counter=123")
+
+ #--------------------------------------------------------------------------------
+ # label param
+ #--------------------------------------------------------------------------------
+
+ # w/ encoded space
+ otp = from_uri("otpauth://hotp/Provider1:Alice%20Smith?secret=JBSWY3DPEHPK3PXP&"
+ "counter=123&issuer=Provider1")
+ self.assertEqual(otp.label, "Alice Smith")
+ self.assertEqual(otp.issuer, "Provider1")
+
+ # w/ encoded space and colon
+ # (note url has leading space before 'alice')
+ otp = from_uri("otpauth://hotp/Big%20Corporation%3A%20alice@bigco.com?"
+ "secret=JBSWY3DPEHPK3PXP&counter=123")
+ self.assertEqual(otp.label, "alice@bigco.com")
+ self.assertEqual(otp.issuer, "Big Corporation")
+
+ #--------------------------------------------------------------------------------
+ # issuer param / prefix
+ #--------------------------------------------------------------------------------
+
+ # 'new style' issuer only
+ otp = from_uri("otpauth://hotp/alice@bigco.com?secret=JBSWY3DPEHPK3PXP&counter=123&"
+ "issuer=Big%20Corporation")
+ self.assertEqual(otp.label, "alice@bigco.com")
+ self.assertEqual(otp.issuer, "Big Corporation")
+
+ # new-vs-old issuer mismatch
+ self.assertRaises(ValueError, from_uri, "otpauth://hotp/Provider1:alice?"
+ "secret=JBSWY3DPEHPK3PXP&counter=123&"
+ "issuer=Provider2")
+
+ #--------------------------------------------------------------------------------
+ # algorithm param
+ #--------------------------------------------------------------------------------
+
+ # custom alg
+ otp = from_uri("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "counter=123&algorithm=SHA256")
+ self.assertEqual(otp.alg, "sha256")
+
+ # unknown alg
+ with self.assertWarningList([
+ dict(category=exc.PasslibRuntimeWarning, message_re="unknown hash.*SHA333")
+ ]):
+ self.assertRaises(ValueError, from_uri, "otpauth://hotp/Example:alice@google.com?"
+ "secret=JBSWY3DPEHPK3PXP&counter=123"
+ "&algorithm=SHA333")
+
+ #--------------------------------------------------------------------------------
+ # digit param
+ #--------------------------------------------------------------------------------
+
+ # custom digits
+ otp = from_uri("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "counter=123&digits=8")
+ self.assertEqual(otp.digits, 8)
+
+ # digits out of range / invalid
+ self.assertRaises(ValueError, from_uri, "otpauth://hotp/Example:alice@google.com?"
+ "secret=JBSWY3DPEHPK3PXP&counter=123&digits=A")
+
+ self.assertRaises(ValueError, from_uri, "otpauth://hotp/Example:alice@google.com?"
+ "secret=JBSWY3DPEHPK3PXP&counter=123&digits=%20")
+
+ self.assertRaises(ValueError, from_uri, "otpauth://hotp/Example:alice@google.com?"
+ "secret=JBSWY3DPEHPK3PXP&counter=123&digits=15")
+
+ #--------------------------------------------------------------------------------
+ # counter param
+ # (deserializing should also set 'start' value)
+ #--------------------------------------------------------------------------------
+
+ # zero counter
+ otp = from_uri("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&counter=0")
+ self.assertEqual(otp.counter, 0)
+ self.assertEqual(otp.start, 0)
+
+ # custom counter
+ otp = from_uri("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&counter=456")
+ self.assertEqual(otp.counter, 456)
+ self.assertEqual(otp.start, 456)
+
+ # reject missing counter
+ self.assertRaises(ValueError, from_uri, "otpauth://hotp/Example:alice@google.com?"
+ "secret=JBSWY3DPEHPK3PXP")
+
+ # reject negative counter
+ self.assertRaises(ValueError, from_uri, "otpauth://hotp/Example:alice@google.com?"
+ "secret=JBSWY3DPEHPK3PXP&counter=-1")
+
+ #--------------------------------------------------------------------------------
+ # unrecognized param
+ #--------------------------------------------------------------------------------
+
+ # should issue warning, but otherwise ignore extra param
+ with self.assertWarningList([
+ dict(category=exc.PasslibRuntimeWarning, message_re="unexpected parameters encountered")
+ ]):
+ otp = from_uri("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "foo=bar&counter=123")
+ self.assertEqual(otp.base32_key, KEY4)
+ self.assertEqual(otp.counter, 123)
+
+ def test_to_uri(self):
+ """to_uri()"""
+
+ #-------------------------------------------------------------------------
+ # label & issuer parameters
+ #-------------------------------------------------------------------------
+
+ # with label & issuer
+ otp = HOTP(KEY4, alg="sha1", digits=6, counter=0)
+ self.assertEqual(otp.to_uri("alice@google.com", "Example Org"),
+ "otpauth://hotp/alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "counter=0&issuer=Example%20Org")
+
+ # label is required
+ self.assertRaises(ValueError, otp.to_uri, None, "Example Org")
+
+ # with label only
+ self.assertEqual(otp.to_uri("alice@google.com"),
+ "otpauth://hotp/alice@google.com?secret=JBSWY3DPEHPK3PXP&counter=0")
+
+ # with default label from constructor
+ otp.label = "alice@google.com"
+ self.assertEqual(otp.to_uri(),
+ "otpauth://hotp/alice@google.com?secret=JBSWY3DPEHPK3PXP&counter=0")
+
+ # with default label & default issuer from constructor
+ otp.issuer = "Example Org"
+ self.assertEqual(otp.to_uri(),
+ "otpauth://hotp/alice@google.com?secret=JBSWY3DPEHPK3PXP&counter=0"
+ "&issuer=Example%20Org")
+
+ # reject invalid label
+ self.assertRaises(ValueError, otp.to_uri, "label:with:semicolons")
+
+ # reject invalid issue
+ self.assertRaises(ValueError, otp.to_uri, "alice@google.com", "issuer:with:semicolons")
+
+ #-------------------------------------------------------------------------
+ # algorithm parameter
+ #-------------------------------------------------------------------------
+ self.assertEqual(HOTP(KEY4, alg="sha256").to_uri("alice@google.com"),
+ "otpauth://hotp/alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "algorithm=SHA256&counter=0")
+
+ #-------------------------------------------------------------------------
+ # digits parameter
+ #-------------------------------------------------------------------------
+ self.assertEqual(HOTP(KEY4, digits=8).to_uri("alice@google.com"),
+ "otpauth://hotp/alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "digits=8&counter=0")
+
+ #-------------------------------------------------------------------------
+ # counter parameter
+ #-------------------------------------------------------------------------
+ self.assertEqual(HOTP(KEY4, counter=456).to_uri("alice@google.com"),
+ "otpauth://hotp/alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "counter=456")
+
+ # sanity check that start parameter is NOT the one being used.
+ otp = HOTP(KEY4, start=123, counter=456)
+ self.assertEqual(otp.start, 123)
+ self.assertEqual(otp.to_uri("alice@google.com"),
+ "otpauth://hotp/alice@google.com?secret=JBSWY3DPEHPK3PXP&"
+ "counter=456")
+
+ #=============================================================================
+ # json serialization
+ #=============================================================================
+
+ # TODO: from_string()
+ # with uri
+ # without needed password
+ # with needed password
+ # with bad version, decode error
+
+ # TODO: to_string()
+ # with password
+ # with custom cost
+ # with password=True
+
+ # TODO: test 'counter' and 'start' are preserved.
+
+ #=============================================================================
+ # eoc
+ #=============================================================================
+
+#=============================================================================
+# eof
+#=============================================================================
diff --git a/passlib/tests/test_utils.py b/passlib/tests/test_utils.py
index 936b739..4985801 100644
--- a/passlib/tests/test_utils.py
+++ b/passlib/tests/test_utils.py
@@ -4,19 +4,12 @@
#=============================================================================
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, SUPPORTS_DIR_METHOD
-from passlib.tests.utils import TestCase, catch_warnings
-
-def hb(source):
- return unhexlify(b(source))
+from passlib.utils.compat import irange, PY3, u, unicode, join_bytes
+from passlib.tests.utils import TestCase
#=============================================================================
# byte funcs
@@ -35,9 +28,8 @@ class MiscTest(TestCase):
# test synthentic dir()
dir(compat)
- if SUPPORTS_DIR_METHOD:
- self.assertTrue('UnicodeIO' in dir(compat))
- self.assertTrue('irange' in dir(compat))
+ self.assertTrue('UnicodeIO' in dir(compat))
+ self.assertTrue('irange' in dir(compat))
def test_classproperty(self):
from passlib.utils import classproperty
@@ -122,15 +114,15 @@ class MiscTest(TestCase):
self.assertEqual(f('a',5), 'aaaaa')
# letters
- x = f(u('abc'), 16)
- y = f(u('abc'), 16)
+ x = f(u('abc'), 32)
+ y = f(u('abc'), 32)
self.assertIsInstance(x, unicode)
self.assertNotEqual(x,y)
self.assertEqual(sorted(set(x)), [u('a'),u('b'),u('c')])
# bytes
- x = f(b('abc'), 16)
- y = f(b('abc'), 16)
+ x = f(b'abc', 32)
+ y = f(b'abc', 32)
self.assertIsInstance(x, bytes)
self.assertNotEqual(x,y)
# NOTE: decoding this due to py3 bytes
@@ -181,15 +173,15 @@ class MiscTest(TestCase):
# test ascii password
h1 = u('aaqPiZY5xR5l.')
self.assertEqual(safe_crypt(u('test'), u('aa')), h1)
- self.assertEqual(safe_crypt(b('test'), b('aa')), h1)
+ self.assertEqual(safe_crypt(b'test', b'aa'), h1)
# test utf-8 / unicode password
h2 = u('aahWwbrUsKZk.')
self.assertEqual(safe_crypt(u('test\u1234'), 'aa'), h2)
- self.assertEqual(safe_crypt(b('test\xe1\x88\xb4'), 'aa'), h2)
+ self.assertEqual(safe_crypt(b'test\xe1\x88\xb4', 'aa'), h2)
# test latin-1 password
- hash = safe_crypt(b('test\xff'), 'aa')
+ hash = safe_crypt(b'test\xff', 'aa')
if PY3: # py3 supports utf-8 bytes only.
self.assertEqual(hash, None)
else: # but py2 is fine.
@@ -226,18 +218,18 @@ class MiscTest(TestCase):
from passlib.utils import consteq
# ensure error raises for wrong types
- self.assertRaises(TypeError, consteq, u(''), b(''))
+ self.assertRaises(TypeError, consteq, u(''), b'')
self.assertRaises(TypeError, consteq, u(''), 1)
self.assertRaises(TypeError, consteq, u(''), None)
- self.assertRaises(TypeError, consteq, b(''), u(''))
- self.assertRaises(TypeError, consteq, b(''), 1)
- self.assertRaises(TypeError, consteq, b(''), None)
+ self.assertRaises(TypeError, consteq, b'', u(''))
+ self.assertRaises(TypeError, consteq, b'', 1)
+ self.assertRaises(TypeError, consteq, b'', None)
self.assertRaises(TypeError, consteq, None, u(''))
- self.assertRaises(TypeError, consteq, None, b(''))
+ self.assertRaises(TypeError, consteq, None, b'')
self.assertRaises(TypeError, consteq, 1, u(''))
- self.assertRaises(TypeError, consteq, 1, b(''))
+ self.assertRaises(TypeError, consteq, 1, b'')
# check equal inputs compare correctly
for value in [
@@ -311,7 +303,7 @@ class MiscTest(TestCase):
# invalid types
self.assertRaises(TypeError, sp, None)
self.assertRaises(TypeError, sp, 1)
- self.assertRaises(TypeError, sp, b(''))
+ self.assertRaises(TypeError, sp, b'')
# empty strings
self.assertEqual(sp(u('')), u(''))
@@ -406,37 +398,37 @@ class CodecTest(TestCase):
import __builtin__ as builtins
self.assertIs(bytes, builtins.str)
- self.assertIsInstance(b(''), bytes)
- self.assertIsInstance(b('\x00\xff'), bytes)
+ self.assertIsInstance(b'', bytes)
+ self.assertIsInstance(b'\x00\xff', bytes)
if PY3:
- self.assertEqual(b('\x00\xff').decode("latin-1"), "\x00\xff")
+ self.assertEqual(b'\x00\xff'.decode("latin-1"), "\x00\xff")
else:
- self.assertEqual(b('\x00\xff'), "\x00\xff")
+ self.assertEqual(b'\x00\xff', "\x00\xff")
def test_to_bytes(self):
"""test to_bytes()"""
from passlib.utils import to_bytes
# check unicode inputs
- self.assertEqual(to_bytes(u('abc')), b('abc'))
- self.assertEqual(to_bytes(u('\x00\xff')), b('\x00\xc3\xbf'))
+ self.assertEqual(to_bytes(u('abc')), b'abc')
+ self.assertEqual(to_bytes(u('\x00\xff')), b'\x00\xc3\xbf')
# check unicode w/ encodings
- self.assertEqual(to_bytes(u('\x00\xff'), 'latin-1'), b('\x00\xff'))
+ self.assertEqual(to_bytes(u('\x00\xff'), 'latin-1'), b'\x00\xff')
self.assertRaises(ValueError, to_bytes, u('\x00\xff'), 'ascii')
# check bytes inputs
- self.assertEqual(to_bytes(b('abc')), b('abc'))
- self.assertEqual(to_bytes(b('\x00\xff')), b('\x00\xff'))
- self.assertEqual(to_bytes(b('\x00\xc3\xbf')), b('\x00\xc3\xbf'))
+ self.assertEqual(to_bytes(b'abc'), b'abc')
+ self.assertEqual(to_bytes(b'\x00\xff'), b'\x00\xff')
+ self.assertEqual(to_bytes(b'\x00\xc3\xbf'), b'\x00\xc3\xbf')
# check byte inputs ignores enocding
- self.assertEqual(to_bytes(b('\x00\xc3\xbf'), "latin-1"),
- b('\x00\xc3\xbf'))
+ self.assertEqual(to_bytes(b'\x00\xc3\xbf', "latin-1"),
+ b'\x00\xc3\xbf')
# check bytes transcoding
- self.assertEqual(to_bytes(b('\x00\xc3\xbf'), "latin-1", "", "utf-8"),
- b('\x00\xff'))
+ self.assertEqual(to_bytes(b'\x00\xc3\xbf', "latin-1", "", "utf-8"),
+ b'\x00\xff')
# check other
self.assertRaises(AssertionError, to_bytes, 'abc', None)
@@ -454,11 +446,11 @@ class CodecTest(TestCase):
self.assertEqual(to_unicode(u('\x00\xff'), "ascii"), u('\x00\xff'))
# check bytes input
- self.assertEqual(to_unicode(b('abc')), u('abc'))
- self.assertEqual(to_unicode(b('\x00\xc3\xbf')), u('\x00\xff'))
- self.assertEqual(to_unicode(b('\x00\xff'), 'latin-1'),
+ self.assertEqual(to_unicode(b'abc'), u('abc'))
+ self.assertEqual(to_unicode(b'\x00\xc3\xbf'), u('\x00\xff'))
+ self.assertEqual(to_unicode(b'\x00\xff', 'latin-1'),
u('\x00\xff'))
- self.assertRaises(ValueError, to_unicode, b('\x00\xff'))
+ self.assertRaises(ValueError, to_unicode, b'\x00\xff')
# check other
self.assertRaises(AssertionError, to_unicode, 'abc', None)
@@ -470,26 +462,26 @@ class CodecTest(TestCase):
# test plain ascii
self.assertEqual(to_native_str(u('abc'), 'ascii'), 'abc')
- self.assertEqual(to_native_str(b('abc'), 'ascii'), 'abc')
+ self.assertEqual(to_native_str(b'abc', 'ascii'), 'abc')
# test invalid ascii
if PY3:
self.assertEqual(to_native_str(u('\xE0'), 'ascii'), '\xE0')
- self.assertRaises(UnicodeDecodeError, to_native_str, b('\xC3\xA0'),
+ self.assertRaises(UnicodeDecodeError, to_native_str, b'\xC3\xA0',
'ascii')
else:
self.assertRaises(UnicodeEncodeError, to_native_str, u('\xE0'),
'ascii')
- self.assertEqual(to_native_str(b('\xC3\xA0'), 'ascii'), '\xC3\xA0')
+ self.assertEqual(to_native_str(b'\xC3\xA0', 'ascii'), '\xC3\xA0')
# test latin-1
self.assertEqual(to_native_str(u('\xE0'), 'latin-1'), '\xE0')
- self.assertEqual(to_native_str(b('\xE0'), 'latin-1'), '\xE0')
+ self.assertEqual(to_native_str(b'\xE0', 'latin-1'), '\xE0')
# test utf-8
self.assertEqual(to_native_str(u('\xE0'), 'utf-8'),
'\xE0' if PY3 else '\xC3\xA0')
- self.assertEqual(to_native_str(b('\xC3\xA0'), 'utf-8'),
+ self.assertEqual(to_native_str(b'\xC3\xA0', 'utf-8'),
'\xE0' if PY3 else '\xC3\xA0')
# other types rejected
@@ -498,9 +490,9 @@ class CodecTest(TestCase):
def test_is_ascii_safe(self):
"""test is_ascii_safe()"""
from passlib.utils import is_ascii_safe
- self.assertTrue(is_ascii_safe(b("\x00abc\x7f")))
+ self.assertTrue(is_ascii_safe(b"\x00abc\x7f"))
self.assertTrue(is_ascii_safe(u("\x00abc\x7f")))
- self.assertFalse(is_ascii_safe(b("\x00abc\x80")))
+ self.assertFalse(is_ascii_safe(b"\x00abc\x80"))
self.assertFalse(is_ascii_safe(u("\x00abc\x80")))
def test_is_same_codec(self):
@@ -562,7 +554,7 @@ class _Base64Test(TestCase):
encoded_ints = None
# invalid encoded byte
- bad_byte = b("?")
+ bad_byte = b"?"
# helper to generate bytemap-specific strings
def m(self, *offsets):
@@ -604,7 +596,7 @@ class _Base64Test(TestCase):
engine = self.engine
m = self.m
decode = engine.decode_bytes
- BNULL = b("\x00")
+ BNULL = b"\x00"
# length == 2 mod 4: 4 bits of padding
self.assertEqual(decode(m(0,0)), BNULL)
@@ -729,13 +721,13 @@ class _Base64Test(TestCase):
transposed = [
# orig, result, transpose map
- (b("\x33\x22\x11"), b("\x11\x22\x33"),[2,1,0]),
- (b("\x22\x33\x11"), b("\x11\x22\x33"),[1,2,0]),
+ (b"\x33\x22\x11", b"\x11\x22\x33",[2,1,0]),
+ (b"\x22\x33\x11", b"\x11\x22\x33",[1,2,0]),
]
transposed_dups = [
# orig, result, transpose projection
- (b("\x11\x11\x22"), b("\x11\x22\x33"),[0,0,1]),
+ (b"\x11\x11\x22", b"\x11\x22\x33",[0,0,1]),
]
def test_encode_transposed_bytes(self):
@@ -869,22 +861,22 @@ class H64_Test(_Base64Test):
encoded_data = [
# test lengths 0..6 to ensure tail is encoded properly
- (b(""),b("")),
- (b("\x55"),b("J/")),
- (b("\x55\xaa"),b("Jd8")),
- (b("\x55\xaa\x55"),b("JdOJ")),
- (b("\x55\xaa\x55\xaa"),b("JdOJe0")),
- (b("\x55\xaa\x55\xaa\x55"),b("JdOJeK3")),
- (b("\x55\xaa\x55\xaa\x55\xaa"),b("JdOJeKZe")),
+ (b"",b""),
+ (b"\x55",b"J/"),
+ (b"\x55\xaa",b"Jd8"),
+ (b"\x55\xaa\x55",b"JdOJ"),
+ (b"\x55\xaa\x55\xaa",b"JdOJe0"),
+ (b"\x55\xaa\x55\xaa\x55",b"JdOJeK3"),
+ (b"\x55\xaa\x55\xaa\x55\xaa",b"JdOJeKZe"),
# test padding bits are null
- (b("\x55\xaa\x55\xaf"),b("JdOJj0")), # len = 1 mod 3
- (b("\x55\xaa\x55\xaa\x5f"),b("JdOJey3")), # len = 2 mod 3
+ (b"\x55\xaa\x55\xaf",b"JdOJj0"), # len = 1 mod 3
+ (b"\x55\xaa\x55\xaa\x5f",b"JdOJey3"), # len = 2 mod 3
]
encoded_ints = [
- (b("z."), 63, 12),
- (b(".z"), 4032, 12),
+ (b"z.", 63, 12),
+ (b".z", 4032, 12),
]
class H64Big_Test(_Base64Test):
@@ -894,22 +886,22 @@ class H64Big_Test(_Base64Test):
encoded_data = [
# test lengths 0..6 to ensure tail is encoded properly
- (b(""),b("")),
- (b("\x55"),b("JE")),
- (b("\x55\xaa"),b("JOc")),
- (b("\x55\xaa\x55"),b("JOdJ")),
- (b("\x55\xaa\x55\xaa"),b("JOdJeU")),
- (b("\x55\xaa\x55\xaa\x55"),b("JOdJeZI")),
- (b("\x55\xaa\x55\xaa\x55\xaa"),b("JOdJeZKe")),
+ (b"",b""),
+ (b"\x55",b"JE"),
+ (b"\x55\xaa",b"JOc"),
+ (b"\x55\xaa\x55",b"JOdJ"),
+ (b"\x55\xaa\x55\xaa",b"JOdJeU"),
+ (b"\x55\xaa\x55\xaa\x55",b"JOdJeZI"),
+ (b"\x55\xaa\x55\xaa\x55\xaa",b"JOdJeZKe"),
# test padding bits are null
- (b("\x55\xaa\x55\xaf"),b("JOdJfk")), # len = 1 mod 3
- (b("\x55\xaa\x55\xaa\x5f"),b("JOdJeZw")), # len = 2 mod 3
+ (b"\x55\xaa\x55\xaf",b"JOdJfk"), # len = 1 mod 3
+ (b"\x55\xaa\x55\xaa\x5f",b"JOdJeZw"), # len = 2 mod 3
]
encoded_ints = [
- (b(".z"), 63, 12),
- (b("z."), 4032, 12),
+ (b".z", 63, 12),
+ (b"z.", 4032, 12),
]
#=============================================================================
diff --git a/passlib/tests/test_utils_crypto.py b/passlib/tests/test_utils_crypto.py
index 0784ef3..4c909f4 100644
--- a/passlib/tests/test_utils_crypto.py
+++ b/passlib/tests/test_utils_crypto.py
@@ -6,9 +6,6 @@ from __future__ import with_statement
# core
from binascii import hexlify, unhexlify
import hashlib
-import hmac
-import sys
-import random
import warnings
# site
try:
@@ -17,15 +14,21 @@ except ImportError:
M2Crypto = None
# pkg
# module
-from passlib.utils.compat import b, bytes, bascii_to_str, irange, PY2, PY3, u, \
- unicode, join_bytes, PYPY, JYTHON
-from passlib.tests.utils import TestCase, TEST_MODE, catch_warnings, skipUnless, skipIf
+from passlib.utils.compat import bascii_to_str, PY3, u, JYTHON
+from passlib.tests.utils import TestCase, TEST_MODE, skipUnless
#=============================================================================
# support
#=============================================================================
def hb(source):
- return unhexlify(b(source))
+ """
+ helper for represent byte strings in hex.
+
+ usage: ``hb("deadbeef23")``
+ """
+ if PY3:
+ source = source.encode("ascii")
+ return unhexlify(source)
#=============================================================================
# test assorted crypto helpers
@@ -60,11 +63,11 @@ class CryptoTest(TestCase):
# test types
self.assertEqual(norm_hash_name(u("MD4")), "md4")
- self.assertEqual(norm_hash_name(b("MD4")), "md4")
+ self.assertEqual(norm_hash_name(b"MD4"), "md4")
self.assertRaises(TypeError, norm_hash_name, None)
# test selected results
- with catch_warnings():
+ with warnings.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):
@@ -75,7 +78,57 @@ class CryptoTest(TestCase):
"name=%r, format=%r:" % (value,
format))
- # TODO: write full test of get_prf(), currently relying on pbkdf2 testing
+ def test_get_hash_info(self):
+ """test get_hash_info()"""
+ import hashlib
+ from passlib.utils.pbkdf2 import get_hash_info
+
+ # invalid names should be rejected
+ self.assertRaises(ValueError, get_hash_info, "new")
+ self.assertRaises(ValueError, get_hash_info, "__name__")
+
+ # 1. should return hashlib builtin if found
+ self.assertEqual(get_hash_info("md5"), (hashlib.md5, 16, 64))
+
+ # 2. should return wrapper around hashlib.new() if found
+ try:
+ hashlib.new("sha")
+ has_sha = True
+ except ValueError:
+ has_sha = False
+ if has_sha:
+ record = get_hash_info("sha")
+ const = record[0]
+ self.assertEqual(record, (const, 20, 64))
+ self.assertEqual(hexlify(const(b"abc").digest()),
+ b"0164b8a914cd2a5e74c4f7ff082c4d97f1edf880")
+
+ else:
+ self.assertRaises(ValueError, get_hash_info, "sha")
+
+ # 3. should fall back to builtin md4
+ try:
+ hashlib.new("md4")
+ has_md4 = True
+ except ValueError:
+ has_md4 = False
+ record = get_hash_info("md4")
+ const = record[0]
+ if not has_md4:
+ from passlib.utils.md4 import md4
+ self.assertIs(const, md4)
+ self.assertEqual(record, (const, 16, 64))
+ self.assertEqual(hexlify(const(b"abc").digest()),
+ b"a448017aaf21d8525fc10ae87aa6729d")
+
+ # 4. unknown names should be rejected
+ self.assertRaises(ValueError, get_hash_info, "xxx256")
+
+ # should memoize records
+ self.assertIs(get_hash_info("md5"), get_hash_info("md5"))
+
+ # TODO: write full test of get_prf()
+ # TODO: write full test of get_keyed_prf() -- currently relying on pbkdf2() tests
#=============================================================================
# test DES routines
@@ -140,11 +193,11 @@ class DesTest(TestCase):
# too large
self.assertRaises(ValueError, expand_des_key, INT_56_MASK+1)
- self.assertRaises(ValueError, expand_des_key, b("\x00")*8)
+ 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)
+ self.assertRaises(ValueError, expand_des_key, b"\x00"*6)
def test_02_shrink(self):
"""test shrink_des_key()"""
@@ -165,11 +218,11 @@ class DesTest(TestCase):
# too large
self.assertRaises(ValueError, shrink_des_key, INT_64_MASK+1)
- self.assertRaises(ValueError, shrink_des_key, b("\x00")*9)
+ 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)
+ self.assertRaises(ValueError, shrink_des_key, b"\x00"*7)
def _random_parity(self, key):
"""randomize parity bits"""
@@ -208,13 +261,13 @@ class DesTest(TestCase):
(key, key3, plaintext))
# check invalid keys
- stub = b('\x00') * 8
+ stub = b'\x00' * 8
self.assertRaises(TypeError, des_encrypt_block, 0, stub)
- self.assertRaises(ValueError, des_encrypt_block, b('\x00')*6, 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)
+ self.assertRaises(ValueError, des_encrypt_block, stub, b'\x00'*7)
# check invalid salts
self.assertRaises(ValueError, des_encrypt_block, stub, stub, salt=-1)
@@ -242,11 +295,11 @@ class DesTest(TestCase):
(key, key3, plaintext))
# check invalid keys
- self.assertRaises(TypeError, des_encrypt_int_block, b('\x00'), 0)
+ 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(TypeError, des_encrypt_int_block, 0, b'\x00')
self.assertRaises(ValueError, des_encrypt_int_block, 0, -1)
# check invalid salts
@@ -275,19 +328,19 @@ class _MD4_Test(TestCase):
vectors = [
# input -> hex digest
# test vectors from http://www.faqs.org/rfcs/rfc1320.html - A.5
- (b(""), "31d6cfe0d16ae931b73c59d7e0c089c0"),
- (b("a"), "bde52cb31de33e46245e05fbdbd6fb24"),
- (b("abc"), "a448017aaf21d8525fc10ae87aa6729d"),
- (b("message digest"), "d9130a8164549fe818874806e1c7014b"),
- (b("abcdefghijklmnopqrstuvwxyz"), "d79e1c308aa5bbcdeea8ed63df412da9"),
- (b("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"), "043f8582f241db351ce627e153e7f0e4"),
- (b("12345678901234567890123456789012345678901234567890123456789012345678901234567890"), "e33b4ddc9c38f2199c3e7b164fcc0536"),
+ (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"""
from passlib.utils.md4 import md4
- h = md4(b(''))
+ h = md4(b'')
self.assertEqual(h.hexdigest(), "31d6cfe0d16ae931b73c59d7e0c089c0")
# NOTE: under py2, hashlib methods try to encode to ascii,
@@ -295,10 +348,10 @@ class _MD4_Test(TestCase):
if PY3 or self._disable_native:
self.assertRaises(TypeError, h.update, u('x'))
- h.update(b('a'))
+ h.update(b'a')
self.assertEqual(h.hexdigest(), "bde52cb31de33e46245e05fbdbd6fb24")
- h.update(b('bcdefghijklmnopqrstuvwxyz'))
+ h.update(b'bcdefghijklmnopqrstuvwxyz')
self.assertEqual(h.hexdigest(), "d79e1c308aa5bbcdeea8ed63df412da9")
def test_md4_hexdigest(self):
@@ -318,25 +371,24 @@ class _MD4_Test(TestCase):
def test_md4_copy(self):
"""test md4 copy()"""
from passlib.utils.md4 import md4
- h = md4(b('abc'))
+ h = md4(b'abc')
h2 = h.copy()
- h2.update(b('def'))
+ h2.update(b'def')
self.assertEqual(h2.hexdigest(), '804e7f1c2586e50b49ac65db5b645131')
- h.update(b('ghi'))
+ h.update(b'ghi')
self.assertEqual(h.hexdigest(), 'c5225580bfe176f6deeee33dee98732c')
# create subclasses to test with and without native backend
+@skipUnless(has_native_md4, "hashlib lacks ssl support")
class MD4_SSL_Test(_MD4_Test):
descriptionPrefix = "MD4 (ssl version)"
-MD4_SSL_TEST = skipUnless(has_native_md4, "hashlib lacks ssl support")(MD4_SSL_Test)
+@skipUnless(TEST_MODE("full") or not has_native_md4, "skipped under current test mode")
class MD4_Builtin_Test(_MD4_Test):
descriptionPrefix = "MD4 (builtin version)"
_disable_native = True
-MD4_Builtin_Test = skipUnless(TEST_MODE("full") or not has_native_md4,
- "skipped under current test mode")(MD4_Builtin_Test)
#=============================================================================
# test PBKDF1 support
@@ -351,21 +403,21 @@ class Pbkdf1_Test(TestCase):
#
# from http://www.di-mgt.com.au/cryptoKDFs.html
#
- (b('password'), hb('78578E5A5D63CB06'), 1000, 16, 'sha1', hb('dc19847e05c64d2faf10ebfb4a3d2a20')),
+ (b'password', hb('78578E5A5D63CB06'), 1000, 16, 'sha1', hb('dc19847e05c64d2faf10ebfb4a3d2a20')),
#
# custom
#
- (b('password'), b('salt'), 1000, 0, 'md5', b('')),
- (b('password'), b('salt'), 1000, 1, 'md5', hb('84')),
- (b('password'), b('salt'), 1000, 8, 'md5', hb('8475c6a8531a5d27')),
- (b('password'), b('salt'), 1000, 16, 'md5', hb('8475c6a8531a5d27e386cd496457812c')),
- (b('password'), b('salt'), 1000, None, 'md5', hb('8475c6a8531a5d27e386cd496457812c')),
- (b('password'), b('salt'), 1000, None, 'sha1', hb('4a8fd48e426ed081b535be5769892fa396293efb')),
+ (b'password', b'salt', 1000, 0, 'md5', b''),
+ (b'password', b'salt', 1000, 1, 'md5', hb('84')),
+ (b'password', b'salt', 1000, 8, 'md5', hb('8475c6a8531a5d27')),
+ (b'password', b'salt', 1000, 16, 'md5', hb('8475c6a8531a5d27e386cd496457812c')),
+ (b'password', b'salt', 1000, None, 'md5', hb('8475c6a8531a5d27e386cd496457812c')),
+ (b'password', b'salt', 1000, None, 'sha1', hb('4a8fd48e426ed081b535be5769892fa396293efb')),
]
- if not (PYPY or JYTHON):
+ if not JYTHON:
pbkdf1_tests.append(
- (b('password'), b('salt'), 1000, None, 'md4', hb('f7f2e91100a8f96190f2dd177cb26453'))
+ (b'password', b'salt', 1000, None, 'md4', hb('f7f2e91100a8f96190f2dd177cb26453'))
)
def test_known(self):
@@ -378,7 +430,7 @@ class Pbkdf1_Test(TestCase):
def test_border(self):
"""test border cases"""
from passlib.utils.pbkdf2 import pbkdf1
- def helper(secret=b('secret'), salt=b('salt'), rounds=1, keylen=1, hash='md5'):
+ def helper(secret=b'secret', salt=b'salt', rounds=1, keylen=1, hash='md5'):
return pbkdf1(secret, salt, rounds, keylen, hash)
helper()
@@ -403,20 +455,13 @@ class Pbkdf1_Test(TestCase):
#=============================================================================
class _Pbkdf2_Test(TestCase):
"""test pbkdf2() support"""
- _disable_m2crypto = False
def setUp(self):
super(_Pbkdf2_Test, self).setUp()
- import passlib.utils.pbkdf2 as mod
-
- # disable m2crypto support, and use software backend
- if M2Crypto and self._disable_m2crypto:
- self.addCleanup(setattr, mod, "_EVP", mod._EVP)
- mod._EVP = None
-
# flush cached prf functions, since we're screwing with their backend.
- mod._clear_prf_cache()
- self.addCleanup(mod._clear_prf_cache)
+ from passlib.utils.pbkdf2 import _clear_caches
+ _clear_caches()
+ self.addCleanup(_clear_caches)
pbkdf2_test_vectors = [
# (result, secret, salt, rounds, keylen, prf="sha1")
@@ -428,43 +473,43 @@ class _Pbkdf2_Test(TestCase):
# test case 1 / 128 bit
(
hb("cdedb5281bb2f801565a1122b2563515"),
- b("password"), b("ATHENA.MIT.EDUraeburn"), 1, 16
+ b"password", b"ATHENA.MIT.EDUraeburn", 1, 16
),
# test case 2 / 128 bit
(
hb("01dbee7f4a9e243e988b62c73cda935d"),
- b("password"), b("ATHENA.MIT.EDUraeburn"), 2, 16
+ b"password", b"ATHENA.MIT.EDUraeburn", 2, 16
),
# test case 2 / 256 bit
(
hb("01dbee7f4a9e243e988b62c73cda935da05378b93244ec8f48a99e61ad799d86"),
- b("password"), b("ATHENA.MIT.EDUraeburn"), 2, 32
+ b"password", b"ATHENA.MIT.EDUraeburn", 2, 32
),
# test case 3 / 256 bit
(
hb("5c08eb61fdf71e4e4ec3cf6ba1f5512ba7e52ddbc5e5142f708a31e2e62b1e13"),
- b("password"), b("ATHENA.MIT.EDUraeburn"), 1200, 32
+ 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
+ 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
+ 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
+ b"X"*65, b"pass phrase exceeds block size", 1200, 32
),
#
@@ -472,17 +517,17 @@ class _Pbkdf2_Test(TestCase):
#
(
hb("0c60c80f961f0e71f3a9b524af6012062fe037a6"),
- b("password"), b("salt"), 1, 20,
+ b"password", b"salt", 1, 20,
),
(
hb("ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957"),
- b("password"), b("salt"), 2, 20,
+ b"password", b"salt", 2, 20,
),
(
hb("4b007901b765489abead49d926f721d065a429c1"),
- b("password"), b("salt"), 4096, 20,
+ b"password", b"salt", 4096, 20,
),
# just runs too long - could enable if ALL option is set
@@ -494,14 +539,14 @@ class _Pbkdf2_Test(TestCase):
(
hb("3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038"),
- b("passwordPASSWORDpassword"),
- b("saltSALTsaltSALTsaltSALTsaltSALTsalt"),
+ b"passwordPASSWORDpassword",
+ b"saltSALTsaltSALTsaltSALTsaltSALTsalt",
4096, 25,
),
(
hb("56fa6aa75548099dcc37d7f03425e0c3"),
- b("pass\00word"), b("sa\00lt"), 4096, 16,
+ b"pass\00word", b"sa\00lt", 4096, 16,
),
#
@@ -511,7 +556,7 @@ class _Pbkdf2_Test(TestCase):
hb("887CFF169EA8335235D8004242AA7D6187A41E3187DF0CE14E256D85ED"
"97A97357AAA8FF0A3871AB9EEFF458392F462F495487387F685B7472FC"
"6C29E293F0A0"),
- b("hello"),
+ b"hello",
hb("9290F727ED06C38BA4549EF7DE25CF5642659211B7FC076F2D28FEFD71"
"784BB8D8F6FB244A8CC5C06240631B97008565A120764C0EE9C2CB0073"
"994D79080136"),
@@ -523,11 +568,11 @@ class _Pbkdf2_Test(TestCase):
#
(
hb('e248fb6b13365146f8ac6307cc222812'),
- b("secret"), b("salt"), 10, 16, "hmac-sha1",
+ b"secret", b"salt", 10, 16, "hmac-sha1",
),
(
hb('e248fb6b13365146f8ac6307cc2228127872da6d'),
- b("secret"), b("salt"), 10, None, "hmac-sha1",
+ b"secret", b"salt", 10, None, "hmac-sha1",
),
]
@@ -544,7 +589,7 @@ class _Pbkdf2_Test(TestCase):
def test_border(self):
"""test border cases"""
from passlib.utils.pbkdf2 import pbkdf2
- def helper(secret=b('password'), salt=b('salt'), rounds=1, keylen=None, prf="hmac-sha1"):
+ def helper(secret=b'password', salt=b'salt', rounds=1, keylen=None, prf="hmac-sha1"):
return pbkdf2(secret, salt, rounds, keylen, prf)
helper()
@@ -570,7 +615,7 @@ class _Pbkdf2_Test(TestCase):
def test_default_keylen(self):
"""test keylen==None"""
from passlib.utils.pbkdf2 import pbkdf2
- def helper(secret=b('password'), salt=b('salt'), rounds=1, keylen=None, prf="hmac-sha1"):
+ def helper(secret=b'password', salt=b'salt', rounds=1, keylen=None, prf="hmac-sha1"):
return pbkdf2(secret, salt, rounds, keylen, prf)
self.assertEqual(len(helper(prf='hmac-sha1')), 20)
self.assertEqual(len(helper(prf='hmac-sha256')), 32)
@@ -579,20 +624,28 @@ class _Pbkdf2_Test(TestCase):
"""test custom prf function"""
from passlib.utils.pbkdf2 import pbkdf2
def prf(key, msg):
- return hashlib.md5(key+msg+b('fooey')).digest()
- result = pbkdf2(b('secret'), b('salt'), 1000, 20, prf)
+ return hashlib.md5(key+msg+b'fooey').digest()
+ result = pbkdf2(b'secret', b'salt', 1000, 20, prf)
self.assertEqual(result, hb('5fe7ce9f7e379d3f65cbc66ba8aa6440474a6849'))
-# create subclasses to test with and without m2crypto
+#------------------------------------------------------------------------
+# create subclasses to test with- and without- m2crypto
+#------------------------------------------------------------------------
+@skipUnless(M2Crypto, "M2Crypto not found")
class Pbkdf2_M2Crypto_Test(_Pbkdf2_Test):
descriptionPrefix = "pbkdf2 (m2crypto backend)"
-Pbkdf2_M2Crypto_Test = skipUnless(M2Crypto, "M2Crypto not found")(Pbkdf2_M2Crypto_Test)
+@skipUnless(TEST_MODE("full") or not M2Crypto, "skipped under current test mode")
class Pbkdf2_Builtin_Test(_Pbkdf2_Test):
descriptionPrefix = "pbkdf2 (builtin backend)"
- _disable_m2crypto = True
-Pbkdf2_Builtin_Test = skipUnless(TEST_MODE("full") or not M2Crypto,
- "skipped under current test mode")(Pbkdf2_Builtin_Test)
+
+ def setUp(self):
+ super(Pbkdf2_Builtin_Test, self).setUp()
+ # disable m2crypto support, and force pure-python backend
+ if M2Crypto:
+ import passlib.utils.pbkdf2 as mod
+ self.addCleanup(setattr, mod, "_EVP", mod._EVP)
+ mod._EVP = None
#=============================================================================
# eof
diff --git a/passlib/tests/test_utils_handlers.py b/passlib/tests/test_utils_handlers.py
index 0b5522d..8546eaf 100644
--- a/passlib/tests/test_utils_handlers.py
+++ b/passlib/tests/test_utils_handlers.py
@@ -11,15 +11,12 @@ import warnings
# site
# pkg
from passlib.hash import ldap_md5, sha256_crypt
-from passlib.registry import _unload_handler_name as unload_handler_name, \
- register_crypt_handler, get_crypt_handler
from passlib.exc import MissingBackendError, PasslibHashWarning
-from passlib.utils import getrandstr, JYTHON, rng
-from passlib.utils.compat import b, bytes, bascii_to_str, str_to_uascii, \
- uascii_to_str, unicode, PY_MAX_25, SUPPORTS_DIR_METHOD
+from passlib.utils.compat import str_to_uascii, \
+ uascii_to_str, unicode
import passlib.utils.handlers as uh
-from passlib.tests.utils import HandlerCase, TestCase, catch_warnings, patchAttr
-from passlib.utils.compat import u, PY3
+from passlib.tests.utils import HandlerCase, TestCase
+from passlib.utils.compat import u
# module
log = getLogger(__name__)
@@ -44,8 +41,6 @@ def _makelang(alphabet, size):
class SkeletonTest(TestCase):
"""test hash support classes"""
- patchAttr = patchAttr
-
#===================================================================
# StaticHandler
#===================================================================
@@ -68,11 +63,11 @@ class SkeletonTest(TestCase):
# check default identify method
self.assertTrue(d1.identify(u('_a')))
- self.assertTrue(d1.identify(b('_a')))
+ self.assertTrue(d1.identify(b'_a'))
self.assertTrue(d1.identify(u('_b')))
self.assertFalse(d1.identify(u('_c')))
- self.assertFalse(d1.identify(b('_c')))
+ self.assertFalse(d1.identify(b'_c'))
self.assertFalse(d1.identify(u('a')))
self.assertFalse(d1.identify(u('b')))
self.assertFalse(d1.identify(u('c')))
@@ -83,12 +78,12 @@ class SkeletonTest(TestCase):
self.assertIs(d1.genconfig(), None)
# check default verify method
- self.assertTrue(d1.verify('s', b('_a')))
+ self.assertTrue(d1.verify('s', b'_a'))
self.assertTrue(d1.verify('s',u('_a')))
- self.assertFalse(d1.verify('s', b('_b')))
+ self.assertFalse(d1.verify('s', b'_b'))
self.assertFalse(d1.verify('s',u('_b')))
- self.assertTrue(d1.verify('s', b('_b'), flag=True))
- self.assertRaises(ValueError, d1.verify, 's', b('_c'))
+ self.assertTrue(d1.verify('s', b'_b', flag=True))
+ self.assertRaises(ValueError, d1.verify, 's', b'_c')
self.assertRaises(ValueError, d1.verify, 's', u('_c'))
# check default encrypt method
@@ -121,7 +116,7 @@ class SkeletonTest(TestCase):
secret = secret.encode("utf-8")
if hash is not None and not cls.identify(hash):
raise ValueError("invalid hash")
- return hashlib.sha1(b("xyz") + secret).hexdigest()
+ return hashlib.sha1(b"xyz" + secret).hexdigest()
@classmethod
def verify(cls, secret, hash):
if hash is None:
@@ -204,11 +199,11 @@ class SkeletonTest(TestCase):
self.assertRaises(ValueError, norm_checksum, u('xxyx'))
# wrong type
- self.assertRaises(TypeError, norm_checksum, b('xxyx'))
+ self.assertRaises(TypeError, norm_checksum, b'xxyx')
# relaxed
with self.assertWarningList("checksum should be unicode"):
- self.assertEqual(norm_checksum(b('xxzx'), relaxed=True), u('xxzx'))
+ self.assertEqual(norm_checksum(b'xxzx', relaxed=True), u('xxzx'))
self.assertRaises(TypeError, norm_checksum, 1, relaxed=True)
# test _stub_checksum behavior
@@ -219,20 +214,20 @@ class SkeletonTest(TestCase):
class d1(uh.HasRawChecksum, uh.GenericHandler):
name = 'd1'
checksum_size = 4
- _stub_checksum = b('0')*4
+ _stub_checksum = b'0'*4
def norm_checksum(*a, **k):
return d1(*a, **k).checksum
# test bytes
- self.assertEqual(norm_checksum(b('1234')), b('1234'))
+ self.assertEqual(norm_checksum(b'1234'), b'1234')
# test unicode
self.assertRaises(TypeError, norm_checksum, u('xxyx'))
self.assertRaises(TypeError, norm_checksum, u('xxyx'), relaxed=True)
# test _stub_checksum behavior
- self.assertIs(norm_checksum(b('0')*4), None)
+ self.assertIs(norm_checksum(b'0'*4), None)
def test_20_norm_salt(self):
"""test GenericHandler + HasSalt mixin"""
@@ -261,7 +256,7 @@ class SkeletonTest(TestCase):
self.assertIn(norm_salt(use_defaults=True), salts3)
# check explicit salts
- with catch_warnings(record=True) as wlog:
+ with warnings.catch_warnings(record=True) as wlog:
# check too-small salts
self.assertRaises(ValueError, norm_salt, salt='')
@@ -282,7 +277,7 @@ class SkeletonTest(TestCase):
self.consumeWarningList(wlog, PasslibHashWarning)
# check generated salts
- with catch_warnings(record=True) as wlog:
+ with warnings.catch_warnings(record=True) as wlog:
# check too-small salt size
self.assertRaises(ValueError, gen_salt, 0)
@@ -332,7 +327,7 @@ class SkeletonTest(TestCase):
self.assertRaises(TypeError, norm_rounds, rounds=1.5)
# check explicit rounds
- with catch_warnings(record=True) as wlog:
+ with warnings.catch_warnings(record=True) as wlog:
# too small
self.assertRaises(ValueError, norm_rounds, rounds=0)
self.consumeWarningList(wlog)
@@ -365,6 +360,74 @@ class SkeletonTest(TestCase):
backends = ("a", "b")
+ @classmethod
+ def _load_backend_a(cls):
+ return None
+
+ @classmethod
+ def _load_backend_b(cls):
+ return None
+
+ def _calc_checksum_a(self, secret):
+ return 'a'
+
+ def _calc_checksum_b(self, secret):
+ return 'b'
+
+ # test no backends
+ self.assertRaises(MissingBackendError, d1.get_backend)
+ self.assertRaises(MissingBackendError, d1.set_backend)
+ self.assertRaises(MissingBackendError, d1.set_backend, 'any')
+ self.assertRaises(MissingBackendError, d1.set_backend, 'default')
+ self.assertFalse(d1.has_backend())
+
+ # enable 'b' backend
+ d1._load_backend_b = classmethod(lambda cls: cls._calc_checksum_b)
+
+ # test lazy load
+ obj = d1()
+ self.assertEqual(obj._calc_checksum('s'), 'b')
+
+ # test repeat load
+ d1.set_backend('b')
+ d1.set_backend('any')
+ self.assertEqual(obj._calc_checksum('s'), 'b')
+
+ # test unavailable
+ self.assertRaises(MissingBackendError, d1.set_backend, 'a')
+ self.assertTrue(d1.has_backend('b'))
+ self.assertFalse(d1.has_backend('a'))
+
+ # enable 'a' backend also
+ d1._load_backend_a = classmethod(lambda cls: cls._calc_checksum_a)
+
+ # test explicit
+ self.assertTrue(d1.has_backend())
+ d1.set_backend('a')
+ self.assertEqual(obj._calc_checksum('s'), 'a')
+
+ # test unknown backend
+ self.assertRaises(ValueError, d1.set_backend, 'c')
+ self.assertRaises(ValueError, d1.has_backend, 'c')
+
+ # test error thrown if _has & _load are mixed
+ class d2(d1):
+ _has_backend_a = True
+ self.assertRaises(AssertionError, d2.has_backend, "a")
+
+ def test_41_backends(self):
+ """test GenericHandler + HasManyBackends mixin (deprecated api)"""
+ warnings.filterwarnings("ignore",
+ category=DeprecationWarning,
+ message=r".* support for \._has_backend_.* is deprecated.*",
+ )
+
+ class d1(uh.HasManyBackends, uh.GenericHandler):
+ name = 'd1'
+ setting_kwds = ()
+
+ backends = ("a", "b")
+
_has_backend_a = False
_has_backend_b = False
@@ -499,9 +562,9 @@ class SkeletonTest(TestCase):
h1 = '$pbkdf2$60000$DoEwpvQeA8B4T.k951yLUQ$O26Y3/NJEiLCVaOVPxGXshyjW8k'
result = hash.pbkdf2_sha1.parsehash(h1)
self.assertEqual(result, dict(
- checksum=b(';n\x98\xdf\xf3I\x12"\xc2U\xa3\x95?\x11\x97\xb2\x1c\xa3[\xc9'),
+ checksum=b';n\x98\xdf\xf3I\x12"\xc2U\xa3\x95?\x11\x97\xb2\x1c\xa3[\xc9',
rounds=60000,
- salt=b('\x0e\x810\xa6\xf4\x1e\x03\xc0xO\xe9=\xe7\\\x8bQ'),
+ salt=b'\x0e\x810\xa6\xf4\x1e\x03\xc0xO\xe9=\xe7\\\x8bQ',
))
# sanitizing of raw checksums & salts
@@ -622,10 +685,7 @@ class PrefixWrapperTest(TestCase):
d2 = uh.PrefixWrapper("d2", "sha256_crypt", "{XXX}")
self.assertIs(d2.setting_kwds, sha256_crypt.setting_kwds)
- if SUPPORTS_DIR_METHOD:
- self.assertTrue('max_rounds' in dir(d2))
- else:
- self.assertFalse('max_rounds' in dir(d2))
+ self.assertTrue('max_rounds' in dir(d2))
def test_11_wrapped_methods(self):
d1 = uh.PrefixWrapper("d1", "ldap_md5", "{XXX}", "{MD5}")
@@ -730,7 +790,7 @@ class UnsaltedHash(uh.StaticHandler):
def _calc_checksum(self, secret):
if isinstance(secret, unicode):
secret = secret.encode("utf-8")
- data = b("boblious") + secret
+ data = b"boblious" + secret
return str_to_uascii(hashlib.sha1(data).hexdigest())
class SaltedHash(uh.HasSalt, uh.GenericHandler):
@@ -783,11 +843,7 @@ class UnsaltedHashTest(HandlerCase):
]
def test_bad_kwds(self):
- if not PY_MAX_25:
- # annoyingly, py25's ``super().__init__()`` doesn't throw TypeError
- # when passing unknown keywords to object. just ignoring
- # this issue for now, since it's a minor border case.
- self.assertRaises(TypeError, UnsaltedHash, salt='x')
+ self.assertRaises(TypeError, UnsaltedHash, salt='x')
self.assertRaises(TypeError, UnsaltedHash.genconfig, rounds=1)
class SaltedHashTest(HandlerCase):
diff --git a/passlib/tests/test_win32.py b/passlib/tests/test_win32.py
index 6bcdaf5..e818b62 100644
--- a/passlib/tests/test_win32.py
+++ b/passlib/tests/test_win32.py
@@ -3,7 +3,6 @@
# imports
#=============================================================================
# core
-from binascii import hexlify
import warnings
# site
# pkg
diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py
index b7e9ca4..eeea4e9 100644
--- a/passlib/tests/utils.py
+++ b/passlib/tests/utils.py
@@ -9,21 +9,21 @@ import re
import os
import sys
import tempfile
+import threading
import time
-from passlib.exc import PasslibHashWarning
-from passlib.utils.compat import PY27, PY_MIN_32, PY3, JYTHON
+from passlib.exc import PasslibHashWarning, PasslibConfigWarning
+from passlib.utils.compat import PY3, JYTHON
import warnings
from warnings import warn
# site
# pkg
from passlib.exc import MissingBackendError
import passlib.registry as registry
-from passlib.tests.backports import TestCase as _TestCase, catch_warnings, skip, skipIf, skipUnless
+from passlib.tests.backports import TestCase as _TestCase, skip, skipIf, skipUnless, SkipTest
from passlib.utils import has_rounds_info, has_salt_info, rounds_cost_values, \
classproperty, rng, getrandstr, is_ascii_safe, to_native_str, \
repeat_string, tick
-from passlib.utils.compat import b, bytes, iteritems, irange, callable, \
- base_string_types, exc_err, u, unicode, PY2
+from passlib.utils.compat import iteritems, irange, u, unicode, PY2
import passlib.utils.handlers as uh
# local
__all__ = [
@@ -102,13 +102,6 @@ def TEST_MODE(min=None, max=None):
#=============================================================================
# hash object inspection
#=============================================================================
-def has_crypt_support(handler):
- """check if host's crypt() supports this natively"""
- if hasattr(handler, "orig_prefix"):
- # ignore wrapper classes
- return False
- return 'os_crypt' in getattr(handler, "backends", ()) and handler.has_backend("os_crypt")
-
def has_relaxed_setting(handler):
"""check if handler supports 'relaxed' kwd"""
# FIXME: I've been lazy, should probably just add 'relaxed' kwd
@@ -124,7 +117,7 @@ def has_relaxed_setting(handler):
def has_active_backend(handler):
"""return active backend for handler, if any"""
if not hasattr(handler, "get_backend"):
- return "builtin"
+ return "always"
try:
return handler.get_backend()
except MissingBackendError:
@@ -141,25 +134,31 @@ def is_default_backend(handler, backend):
finally:
handler.set_backend(orig)
-class temporary_backend(object):
+def iter_alt_backends(handler, current=None, fallback=False):
"""
- temporarily set handler to specific backend
- """
-
- _orig = None
-
- def __init__(self, handler, backend=None):
- self.handler = handler
- self.backend = backend
+ iterate over alternate backends available to handler.
- def __enter__(self):
- orig = self._orig = self.handler.get_backend()
- if self.backend:
- self.handler.set_backend(self.backend)
- return orig
-
- def __exit__(self, *exc_info):
- self.handler.set_backend(self._orig)
+ .. warning::
+ not thread-safe due to has_backend() call
+ """
+ if current is None:
+ current = handler.get_backend()
+ backends = handler.backends
+ idx = backends.index(current)+1 if fallback else 0
+ for backend in backends[idx:]:
+ if backend != current and handler.has_backend(backend):
+ yield backend
+
+def get_alt_backend(*args, **kwds):
+ for backend in iter_alt_backends(*args, **kwds):
+ return backend
+ return None
+
+def unwrap_handler(handler):
+ """return original handler, removing any wrapper objects"""
+ while hasattr(handler, "wrapped"):
+ handler = handler.wrapped
+ return handler
#=============================================================================
# misc helpers
@@ -209,21 +208,6 @@ def quicksleep(delay):
# custom test harness
#=============================================================================
-def patchAttr(test, obj, attr, value):
- """monkeypatch object value, restoring original on cleanup"""
- try:
- orig = getattr(obj, attr)
- except AttributeError:
- def cleanup():
- try:
- delattr(obj, attr)
- except AttributeError:
- pass
- test.addCleanup(cleanup)
- else:
- test.addCleanup(setattr, obj, attr, orig)
- setattr(obj, attr, value)
-
class TestCase(_TestCase):
"""passlib-specific test case class
@@ -264,9 +248,6 @@ class TestCase(_TestCase):
return name.startswith("_") or \
getattr(cls, "_%s__unittest_skip" % name, False)
- # make this mirror nose's '__test__' attr
- return not getattr(cls, "__test__", True)
-
@classproperty
def __test__(cls):
# make nose just proxy __unittest_skip__
@@ -380,7 +361,7 @@ class TestCase(_TestCase):
"WarningMessage instance")
self.assertEqual(wmsg.lineno, lineno, msg)
- class _AssertWarningList(catch_warnings):
+ class _AssertWarningList(warnings.catch_warnings):
"""context manager for assertWarningList()"""
def __init__(self, case, **kwds):
self.case = case
@@ -393,7 +374,7 @@ class TestCase(_TestCase):
def __exit__(self, *exc_info):
self.__super.__exit__(*exc_info)
- if not exc_info:
+ if exc_info[0] is None:
self.case.assertWarningList(self.log, **self.kwds)
def assertWarningList(self, wlist=None, desc=None, msg=None):
@@ -489,6 +470,21 @@ class TestCase(_TestCase):
queue.append(path)
return path
+ def patchAttr(self, obj, attr, value):
+ """monkeypatch object value, restoring original value on cleanup"""
+ try:
+ orig = getattr(obj, attr)
+ except AttributeError:
+ def cleanup():
+ try:
+ delattr(obj, attr)
+ except AttributeError:
+ pass
+ self.addCleanup(cleanup)
+ else:
+ self.addCleanup(setattr, obj, attr, orig)
+ setattr(obj, attr, value)
+
#===================================================================
# eoc
#===================================================================
@@ -567,7 +563,7 @@ class HandlerCase(TestCase):
stock_passwords = [
u("test"),
u("\u20AC\u00A5$"),
- b('\xe2\x82\xac\xc2\xa5$')
+ b'\xe2\x82\xac\xc2\xa5$'
]
#---------------------------------------------------------------
@@ -598,7 +594,7 @@ class HandlerCase(TestCase):
# anything that supports crypt() interface should forbid null chars,
# since crypt() uses null-terminated strings.
if 'os_crypt' in getattr(cls.handler, "backends", ()):
- return b("\x00")
+ return b"\x00"
return None
#===================================================================
@@ -615,12 +611,6 @@ class HandlerCase(TestCase):
return name
#===================================================================
- # internal instance attrs
- #===================================================================
- # indicates safe_crypt() has been patched to use another backend of handler.
- using_patched_crypt = False
-
- #===================================================================
# support methods
#===================================================================
@@ -728,65 +718,44 @@ class HandlerCase(TestCase):
# automatically generate subclasses for testing specific backends,
# and other backend helpers
#---------------------------------------------------------------
+
+ BACKEND_NOT_AVAILABLE = "backend not available"
+
@classmethod
- def _enable_backend_case(cls, backend):
- """helper for create_backend_cases(); returns reason to skip backend, or None"""
+ def _get_skip_backend_reason(cls, backend):
+ """
+ helper for create_backend_case() --
+ returns reason to skip backend, or None if backend should be tested
+ """
handler = cls.handler
if not is_default_backend(handler, backend) and not TEST_MODE("full"):
return "only default backend is being tested"
if handler.has_backend(backend):
return None
- if handler.name == "bcrypt" and backend == "builtin" and TEST_MODE("full"):
- # this will be auto-enabled under TEST_MODE 'full'.
- return None
- from passlib.utils import has_crypt
- if backend == "os_crypt" and has_crypt:
- if TEST_MODE("full") and cls.find_crypt_replacement():
- # in this case, HandlerCase will monkeypatch os_crypt
- # to use another backend, just so we can test os_crypt fully.
- return None
- else:
- return "hash not supported by os crypt()"
- return "backend not available"
+ return cls.BACKEND_NOT_AVAILABLE
@classmethod
- def create_backend_cases(cls, backends, module=None):
+ def create_backend_case(cls, backend):
handler = cls.handler
name = handler.name
assert hasattr(handler, "backends"), "handler must support uh.HasManyBackends protocol"
- for backend in backends:
- assert backend in handler.backends, "unknown backend: %r" % (backend,)
- bases = (cls,)
- if backend == "os_crypt":
- bases += (OsCryptMixin,)
- subcls = type(
- "%s_%s_test" % (name, backend),
- bases,
- dict(
- descriptionPrefix = "%s (%s backend)" % (name, backend),
- backend = backend,
- __module__= module or cls.__module__,
- )
+ assert backend in handler.backends, "unknown backend: %r" % (backend,)
+ bases = (cls,)
+ if backend == "os_crypt":
+ bases += (OsCryptMixin,)
+ subcls = type(
+ "%s_%s_test" % (name, backend),
+ bases,
+ dict(
+ descriptionPrefix="%s (%s backend)" % (name, backend),
+ backend=backend,
+ __module__=cls.__module__,
)
- skip_reason = cls._enable_backend_case(backend)
- if skip_reason:
- subcls = skip(skip_reason)(subcls)
- yield subcls
-
- @classmethod
- def find_crypt_replacement(cls, fallback=False):
- """find other backend which can be used to mock the os_crypt backend"""
- handler = cls.handler
- assert "os_crypt" in handler.backends, "expected os_crypt to be present"
- if fallback:
- # NOTE: using list() because tuples lack .index under py25 (issue 58)
- idx = list(handler.backends).index("os_crypt") + 1
- else:
- idx = 0
- for name in handler.backends[idx:]:
- if name != "os_crypt" and handler.has_backend(name):
- return name
- return None
+ )
+ skip_reason = cls._get_skip_backend_reason(backend)
+ if skip_reason:
+ subcls = skip(skip_reason)(subcls)
+ return subcls
#===================================================================
# setup
@@ -963,38 +932,44 @@ class HandlerCase(TestCase):
def test_05_backends(self):
"""test multi-backend support"""
+
+ # check that handler supports multiple backends
handler = self.handler
if not hasattr(handler, "set_backend"):
raise self.skipTest("handler only has one backend")
- with temporary_backend(handler):
- for backend in handler.backends:
-
- #
- # validate backend name
- #
- self.assertIsInstance(backend, str)
- self.assertNotIn(backend, RESERVED_BACKEND_NAMES,
- "invalid backend name: %r" % (backend,))
-
- #
- # ensure has_backend() returns bool value
- #
- ret = handler.has_backend(backend)
- if ret is True:
- # verify backend can be loaded
- handler.set_backend(backend)
- self.assertEqual(handler.get_backend(), backend)
-
- elif ret is False:
- # verify backend CAN'T be loaded
- self.assertRaises(MissingBackendError, handler.set_backend,
- backend)
- else:
- # didn't return boolean object. commonly fails due to
- # use of 'classmethod' decorator instead of 'classproperty'
- raise TypeError("has_backend(%r) returned invalid "
- "value: %r" % (backend, ret))
+ # add cleanup func to restore old backend
+ self.addCleanup(handler.set_backend, handler.get_backend())
+
+ # run through each backend, make sure it works
+ for backend in handler.backends:
+
+ #
+ # validate backend name
+ #
+ self.assertIsInstance(backend, str)
+ self.assertNotIn(backend, RESERVED_BACKEND_NAMES,
+ "invalid backend name: %r" % (backend,))
+
+ #
+ # ensure has_backend() returns bool value
+ #
+ ret = handler.has_backend(backend)
+ if ret is True:
+ # verify backend can be loaded
+ handler.set_backend(backend)
+ self.assertEqual(handler.get_backend(), backend)
+
+ elif ret is False:
+ # verify backend CAN'T be loaded
+ self.assertRaises(MissingBackendError, handler.set_backend,
+ backend)
+
+ else:
+ # didn't return boolean object. commonly fails due to
+ # use of 'classmethod' decorator instead of 'classproperty'
+ raise TypeError("has_backend(%r) returned invalid "
+ "value: %r" % (backend, ret))
#===================================================================
# salts
@@ -1032,20 +1007,19 @@ class HandlerCase(TestCase):
raise AssertionError("default_salt_size must be <= max_salt_size")
# check for 'salt_size' keyword
- if 'salt_size' not in cls.setting_kwds and \
- (not mx_set or cls.min_salt_size < cls.max_salt_size):
- # NOTE: only bothering to issue warning if default_salt_size
- # isn't maxed out
- if (not mx_set or cls.default_salt_size < cls.max_salt_size):
- warn("%s: hash handler supports range of salt sizes, "
- "but doesn't offer 'salt_size' setting" % (cls.name,))
+ # NOTE: skipping warning if default salt size is already maxed out
+ # (might change that in future)
+ if 'salt_size' not in cls.setting_kwds and (not mx_set or cls.default_salt_size < cls.max_salt_size):
+ warn('%s: hash handler supports range of salt sizes, '
+ 'but doesn\'t offer \'salt_size\' setting' % (cls.name,))
# check salt_chars & default_salt_chars
if cls.salt_chars:
if not cls.default_salt_chars:
raise AssertionError("default_salt_chars must not be empty")
- if any(c not in cls.salt_chars for c in cls.default_salt_chars):
- raise AssertionError("default_salt_chars must be subset of salt_chars: %r not in salt_chars" % (c,))
+ for c in cls.default_salt_chars:
+ if c not in cls.salt_chars:
+ raise AssertionError("default_salt_chars must be subset of salt_chars: %r not in salt_chars" % (c,))
else:
if not cls.default_salt_chars:
raise AssertionError("default_salt_chars MUST be specified if salt_chars is empty")
@@ -1151,7 +1125,7 @@ class HandlerCase(TestCase):
# should accept too-large salt in relaxed mode
#
if has_relaxed_setting(handler):
- with catch_warnings(record=True): # issues passlibhandlerwarning
+ with warnings.catch_warnings(record=True): # issues passlibhandlerwarning
c2 = self.do_genconfig(salt=s2, relaxed=True)
self.assertEqual(c2, c1)
@@ -1227,7 +1201,7 @@ class HandlerCase(TestCase):
# 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'))
+ self.assertRaises(TypeError, self.do_encrypt, 'stub', salt=b'x')
#===================================================================
# rounds
@@ -1300,14 +1274,193 @@ class HandlerCase(TestCase):
# TODO: check relaxed mode clips max+1
+ def test_has_rounds_using_limits(self):
+ """
+ HasRounds.using() -- desired rounds limits & defaults
+ """
+ self.require_rounds_info()
+
+ #-------------------------------------
+ # helpers
+ #-------------------------------------
+ handler = self.handler
+
+ if handler.name == "bsdi_crypt":
+ # hack to bypass bsdi-crypt's "odd rounds only" behavior, messes up this test
+ orig_handler = handler
+ handler = handler.using()
+ handler._generate_rounds = lambda self: super(orig_handler, self)._generate_rounds()
+
+ def effective_rounds(cls, rounds=None):
+ cls = unwrap_handler(cls)
+ return cls(rounds=rounds, use_defaults=True).rounds
+
+ # create some fake values to test with
+ orig_min_rounds = handler.min_rounds
+ orig_max_rounds = handler.max_rounds
+ orig_default_rounds = handler.default_rounds
+ medium = ((orig_max_rounds or 9999) + orig_min_rounds) // 2
+ if medium == orig_default_rounds:
+ medium += 1
+ small = (orig_min_rounds + medium) // 2
+ large = ((orig_max_rounds or 9999) + medium) // 2
+
+ # create a subclass with small/medium/large as new default desired values
+ with self.assertWarningList([]):
+ subcls = handler.using(
+ min_desired_rounds=small,
+ max_desired_rounds=large,
+ default_rounds=medium,
+ )
+
+ #-------------------------------------
+ # sanity check that .using() modified things correctly
+ #-------------------------------------
+
+ # shouldn't affect original handler at all
+ self.assertEqual(handler.min_rounds, orig_min_rounds)
+ self.assertEqual(handler.max_rounds, orig_max_rounds)
+ self.assertEqual(handler.min_desired_rounds, None)
+ self.assertEqual(handler.max_desired_rounds, None)
+ self.assertEqual(handler.default_rounds, orig_default_rounds)
+
+ # should affect subcls' desired value, but not hard min/max
+ self.assertEqual(subcls.min_rounds, orig_min_rounds)
+ self.assertEqual(subcls.max_rounds, orig_max_rounds)
+ self.assertEqual(subcls.default_rounds, medium)
+ self.assertEqual(subcls.min_desired_rounds, small)
+ self.assertEqual(subcls.max_desired_rounds, large)
+
+ #-------------------------------------
+ # min_desired_rounds
+ #-------------------------------------
+
+ # .using() should clip values below valid minimum, w/ warning
+ if orig_min_rounds > 0:
+ with self.assertWarningList([PasslibHashWarning]):
+ temp = handler.using(min_desired_rounds=orig_min_rounds - 1)
+ self.assertEqual(temp.min_desired_rounds, orig_min_rounds)
+
+ # .using() should clip values above valid maximum, w/ warning
+ if orig_max_rounds:
+ with self.assertWarningList([PasslibHashWarning]):
+ temp = handler.using(min_desired_rounds=orig_max_rounds + 1)
+ self.assertEqual(temp.min_desired_rounds, orig_max_rounds)
+
+ # .using() should allow values below previous desired minimum, w/o warning
+ with self.assertWarningList([]):
+ temp = subcls.using(min_desired_rounds=small - 1)
+ self.assertEqual(temp.min_desired_rounds, small - 1)
+
+ # .using() should allow values w/in previous range
+ temp = subcls.using(min_desired_rounds=small + 2)
+ self.assertEqual(temp.min_desired_rounds, small + 2)
+
+ # .using() should allow values above previous desired maximum, w/o warning
+ with self.assertWarningList([]):
+ temp = subcls.using(min_desired_rounds=large + 1)
+ self.assertEqual(temp.min_desired_rounds, large + 1)
+
+ # encrypt() etc should allow explicit values below desired minimum, w/ warning
+ self.assertEqual(effective_rounds(subcls, small + 1), small + 1)
+ self.assertEqual(effective_rounds(subcls, small), small)
+ with self.assertWarningList([PasslibConfigWarning]):
+ self.assertEqual(effective_rounds(subcls, small - 1), small - 1)
+
+ # TODO: test 'min_rounds' alias is honored
+ # TODO: test strings are accepted, bad values like 'x' rejected
+
+ #-------------------------------------
+ # max_rounds
+ #-------------------------------------
+
+ # .using() should clip values below valid minimum w/ warning
+ if orig_min_rounds > 0:
+ with self.assertWarningList([PasslibHashWarning]):
+ temp = handler.using(max_desired_rounds=orig_min_rounds - 1)
+ self.assertEqual(temp.max_desired_rounds, orig_min_rounds)
+
+ # .using() should clip values above valid maximum, w/ warning
+ if orig_max_rounds:
+ with self.assertWarningList([PasslibHashWarning]):
+ temp = handler.using(max_desired_rounds=orig_max_rounds + 1)
+ self.assertEqual(temp.max_desired_rounds, orig_max_rounds)
+
+ # .using() should clip values below previous minimum, w/ warning
+ with self.assertWarningList([PasslibConfigWarning]):
+ temp = subcls.using(max_desired_rounds=small - 1)
+ self.assertEqual(temp.max_desired_rounds, small)
+
+ # .using() should reject explicit min > max
+ self.assertRaises(ValueError, subcls.using,
+ min_desired_rounds=medium+1,
+ max_desired_rounds=medium-1)
+
+ # .using() should allow values w/in previous range
+ temp = subcls.using(min_desired_rounds=large - 2)
+ self.assertEqual(temp.min_desired_rounds, large - 2)
+
+ # .using() should allow values above previous desired maximum, w/o warning
+ with self.assertWarningList([]):
+ temp = subcls.using(max_desired_rounds=large + 1)
+ self.assertEqual(temp.max_desired_rounds, large + 1)
+
+ # encrypt() etc should allow explicit values above desired minimum, w/ warning
+ self.assertEqual(effective_rounds(subcls, large - 1), large - 1)
+ self.assertEqual(effective_rounds(subcls, large), large)
+ with self.assertWarningList([PasslibConfigWarning]):
+ self.assertEqual(effective_rounds(subcls, large + 1), large + 1)
+
+ # TODO: test 'max_rounds' alias is honored
+ # TODO: test strings are accepted, bad values like 'x' rejected
+
+ #-------------------------------------
+ # default_rounds
+ #-------------------------------------
+
+ # XXX: are there any other cases that need testing?
+
+ # implicit default rounds -- increase to min_rounds
+ temp = subcls.using(min_rounds=medium+1)
+ self.assertEqual(temp.default_rounds, medium+1)
+
+ # implicit default rounds -- decrease to max_rounds
+ temp = subcls.using(max_rounds=medium-1)
+ self.assertEqual(temp.default_rounds, medium-1)
+
+ # explicit default rounds below desired minimum
+ # XXX: make this a warning if min is implicit?
+ self.assertRaises(ValueError, subcls.using, default_rounds=small-1)
+
+ # explicit default rounds above desired maximum
+ # XXX: make this a warning if max is implicit?
+ if orig_max_rounds:
+ self.assertRaises(ValueError, subcls.using, default_rounds=large+1)
+
+ # encrypt() etc should implicit default rounds, but get overridden
+ self.assertEqual(effective_rounds(subcls), medium)
+ self.assertEqual(effective_rounds(subcls, medium+1), medium+1)
+
+ # TODO: test 'rounds' alias is honored
+ # TODO: test strings are accepted, bad values like 'x' rejected
+
+ # TODO: HasRounds -- using() -- linear & log vary_rounds.
+ # borrow code from CryptContext's test_51_linear_vary_rounds & friends
+
+ # TODO: HasRounds.needs_update() -- min_desired_rounds / max_desired_rounds checks.
+
#===================================================================
# idents
#===================================================================
+ def require_many_idents(self):
+ handler = self.handler
+ if not isinstance(handler, type) or not issubclass(handler, uh.HasManyIdents):
+ raise self.skipTest("handler doesn't derive from HasManyIdents")
+
def test_30_HasManyIdents(self):
"""validate HasManyIdents configuration"""
cls = self.handler
- if not isinstance(cls, type) or not issubclass(cls, uh.HasManyIdents):
- raise self.skipTest("handler doesn't derive from HasManyIdents")
+ self.require_many_idents()
# check settings
self.assertTrue('ident' in cls.setting_kwds)
@@ -1355,6 +1508,52 @@ class HandlerCase(TestCase):
# TODO: check various supported idents
+ def test_has_many_idents_using(self):
+ """HasManyIdents.using() -- 'default_ident' and 'ident' keywords"""
+ self.require_many_idents()
+
+ # pick alt ident to test with
+ handler = self.handler
+ orig_ident = handler.default_ident
+ for alt_ident in handler.ident_values:
+ if alt_ident != orig_ident:
+ break
+ else:
+ raise AssertionError("expected to find alternate ident: default=%r values=%r" %
+ (orig_ident, handler.ident_values))
+
+ def effective_ident(cls):
+ cls = unwrap_handler(cls)
+ return cls(use_defaults=True).ident
+
+ # keep default if nothing else specified
+ subcls = handler.using()
+ self.assertEqual(subcls.default_ident, orig_ident)
+
+ # accepts alt ident
+ subcls = handler.using(default_ident=alt_ident)
+ self.assertEqual(subcls.default_ident, alt_ident)
+ self.assertEqual(handler.default_ident, orig_ident)
+
+ # check subcls actually *generates* default ident,
+ # and that we didn't affect orig handler
+ self.assertEqual(effective_ident(subcls), alt_ident)
+ self.assertEqual(effective_ident(handler), orig_ident)
+
+ # rejects bad ident
+ self.assertRaises(ValueError, handler.using, default_ident='xXx')
+
+ # honor 'ident' alias
+ subcls = handler.using(ident=alt_ident)
+ self.assertEqual(subcls.default_ident, alt_ident)
+ self.assertEqual(handler.default_ident, orig_ident)
+
+ # forbid both at same time
+ self.assertRaises(TypeError, handler.using, default_ident=alt_ident, ident=alt_ident)
+
+ # TODO: * could check ident aliases are being honored
+ # * could check ident types are being restricted
+
#===================================================================
# passwords
#===================================================================
@@ -1678,7 +1877,7 @@ class HandlerCase(TestCase):
#
# test hash='' is rejected for all but the plaintext hashes
#
- for hash in [u(''), b('')]:
+ for hash in [u(''), b'']:
if self.accepts_all_hashes:
# then it accepts empty string as well.
self.assertTrue(self.do_identify(hash))
@@ -1703,8 +1902,8 @@ class HandlerCase(TestCase):
#===================================================================
# fuzz testing
#===================================================================
- def test_77_fuzz_input(self):
- """test random passwords and options
+ def test_77_fuzz_input(self, threaded=False):
+ """fuzz testing -- random passwords and options
This test attempts to perform some basic fuzz testing of the hash,
based on whatever information can be found about it.
@@ -1719,6 +1918,10 @@ class HandlerCase(TestCase):
* runs output of selected backend against other available backends
(if any) to detect errors occurring between different backends.
* runs output against other "external" verifiers such as OS crypt()
+
+ :param report_thread_state:
+ if true, writes state of loop to current_thread().passlib_fuzz_state.
+ used to help debug multi-threaded fuzz test issues (below)
"""
if self.is_disabled_handler:
raise self.skipTest("not applicable")
@@ -1735,7 +1938,15 @@ class HandlerCase(TestCase):
return (v.__doc__ or v.__name__).splitlines()[0]
# do as many tests as possible for max_time seconds
- stop = tick() + max_time
+ if threaded:
+ tname = threading.current_thread().name
+ else:
+ tname = "fuzz test"
+ log.debug("%s: %s: started; max_time=%r verifiers=%d (%s)",
+ self.descriptionPrefix, tname, max_time, len(verifiers),
+ ", ".join(vname(v) for v in verifiers))
+ start = tick()
+ stop = start + max_time
count = 0
while tick() <= stop:
# generate random password & options
@@ -1766,11 +1977,70 @@ class HandlerCase(TestCase):
raise self.failureException("was able to verify wrong "
"password using %s: wrong_secret=%r real_secret=%r "
"config=%r hash=%r" % (name, other, secret, kwds, hash))
- count +=1
+ count += 1
- log.debug("fuzz test: %r checked %d passwords against %d verifiers (%s)",
- self.descriptionPrefix, count, len(verifiers),
- ", ".join(vname(v) for v in verifiers))
+ log.debug("%s: %s: done; elapsed=%r count=%r",
+ self.descriptionPrefix, tname, tick() - start, count)
+
+ def test_78_fuzz_threading(self):
+ """multithreaded fuzz testing -- random password & options using multiple threads
+
+ run test_77 simultaneously in multiple threads
+ in an attempt to detect any concurrency issues
+ (e.g. the bug fixed by pybcrypt 0.3)
+ """
+ self.require_TEST_MODE("full")
+ import threading
+
+ # check if this test should run
+ if self.is_disabled_handler:
+ raise self.skipTest("not applicable")
+ thread_count = self.fuzz_thread_count
+ if thread_count < 1 or self.max_fuzz_time <= 0:
+ raise self.skipTest("disabled by test mode")
+
+ # buffer to hold errors thrown by threads
+ failed_lock = threading.Lock()
+ failed = [0]
+
+ # launch <thread count> threads, all of which run
+ # test_77_fuzz_input(), and see if any errors get thrown.
+ # if hash has concurrency issues, this should reveal it.
+ def wrapper():
+ try:
+ self.test_77_fuzz_input(threaded=True)
+ except SkipTest:
+ pass
+ except:
+ with failed_lock:
+ failed[0] += 1
+ raise
+ def launch(n):
+ name = "Fuzz-Thread-%d" % (n,)
+ thread = threading.Thread(target=wrapper, name=name)
+ thread.setDaemon(True)
+ thread.start()
+ return thread
+ threads = [launch(n) for n in irange(thread_count)]
+
+ # wait until all threads exit
+ timeout = self.max_fuzz_time * thread_count * 4
+ stalled = 0
+ for thread in threads:
+ thread.join(timeout)
+ if not thread.is_alive():
+ continue
+ # XXX: not sure why this is happening, main one seems 1/4 times for sun_md5_crypt
+ log.error("%s timed out after %f seconds", thread.name, timeout)
+ stalled += 1
+
+ # if any thread threw an error, raise one ourselves.
+ if failed[0]:
+ raise self.fail("%d/%d threads failed concurrent fuzz testing "
+ "(see error log for details)" % (failed[0], thread_count))
+ if stalled:
+ raise self.fail("%d/%d threads stalled during concurrent fuzz testing "
+ "(see error log for details)" % (stalled, thread_count))
#---------------------------------------------------------------
# fuzz constants & helpers
@@ -1795,9 +2065,16 @@ class HandlerCase(TestCase):
else:
return 5
- def os_supports_ident(self, ident):
- """whether native OS crypt() supports particular ident value"""
- return True
+ @property
+ def fuzz_thread_count(self):
+ """number of threads for threaded fuzz testing"""
+ value = int(os.environ.get("PASSLIB_TEST_FUZZ_THREADS") or 0)
+ if value:
+ return value
+ elif TEST_MODE(max="quick"):
+ return 0
+ else:
+ return 10
#---------------------------------------------------------------
# fuzz verifiers
@@ -1822,18 +2099,18 @@ class HandlerCase(TestCase):
verifiers.append(func)
# create verifiers for any other available backends
+ # NOTE: using subclass so we can load alt backend in threadsafe manner
if hasattr(handler, "backends") and TEST_MODE("full"):
def maker(backend):
+ sub_handler = handler.using()
+ sub_handler.set_backend(backend)
def func(secret, hash):
- with temporary_backend(handler, backend):
- return handler.verify(secret, hash)
+ return sub_handler.verify(secret, hash)
func.__name__ = "check_" + backend + "_backend"
func.__doc__ = backend + "-backend"
return func
- cur = handler.get_backend()
- for backend in handler.backends:
- if backend != cur and handler.has_backend(backend):
- verifiers.append(maker(backend))
+ for backend in iter_alt_backends(handler):
+ verifiers.append(maker(backend))
return verifiers
@@ -1847,20 +2124,6 @@ class HandlerCase(TestCase):
check_default.__doc__ = "self"
return check_default
- def fuzz_verifier_crypt(self):
- """test results against OS crypt()"""
- 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
-
#---------------------------------------------------------------
# fuzz settings generation
#---------------------------------------------------------------
@@ -1905,10 +2168,7 @@ class HandlerCase(TestCase):
return None
# resolve wrappers before reading values
handler = getattr(handler, "wrapped", handler)
- ident = rng.choice(handler.ident_values)
- if self.backend == "os_crypt" and not self.using_patched_crypt and not self.os_supports_ident(ident):
- return None
- return ident
+ return rng.choice(handler.ident_values)
#---------------------------------------------------------------
# fuzz password generation
@@ -1962,12 +2222,19 @@ class OsCryptMixin(HandlerCase):
* check that native crypt support is detected correctly for known platforms.
"""
#===================================================================
- # option flags
+ # class attrs
#===================================================================
+
# platforms that are known to support / not support this hash natively.
# list of (platform_regex, True|False|None) entries.
platform_crypt_support = []
+ #: flag indicating backend provides a fallback when safe_crypt() can't handle password
+ has_os_crypt_fallback = True
+
+ #: alternate handler to use when searching for backend to fake safe_crypt() support.
+ alt_safe_crypt_handler = None
+
#===================================================================
# instance attrs
#===================================================================
@@ -1985,13 +2252,26 @@ class OsCryptMixin(HandlerCase):
def setUp(self):
assert self.backend == "os_crypt"
if not self.handler.has_backend("os_crypt"):
- self.handler.get_backend() # hack to prevent recursion issue
self._patch_safe_crypt()
super(OsCryptMixin, self).setUp()
- # alternate handler to use for fake os_crypt,
- # e.g. bcrypt_sha256 uses bcrypt
- fallback_os_crypt_handler = None
+ @classmethod
+ def _get_safe_crypt_handler_backend(cls):
+ """
+ return (handler, backend) pair to use for faking crypt.crypt() support for hash.
+ backend will be None if none availabe.
+ """
+ # find handler that generates safe_crypt() compatible hash
+ handler = cls.alt_safe_crypt_handler
+ if not handler:
+ handler = unwrap_handler(cls.handler)
+
+ # hack to prevent recursion issue when .has_backend() is called
+ handler.get_backend()
+
+ # find backend which isn't os_crypt
+ alt_backend = get_alt_backend(handler, "os_crypt")
+ return handler, alt_backend
def _patch_safe_crypt(self):
"""if crypt() doesn't support current hash alg, this patches
@@ -1999,58 +2279,81 @@ class OsCryptMixin(HandlerCase):
backends, so that we can go ahead and test as much of code path
as possible.
"""
- handler = self.fallback_os_crypt_handler or self.handler
- # resolve wrappers, since we want to return crypt compatible hash.
- while hasattr(handler, "wrapped"):
- handler = handler.wrapped
- alt_backend = self.find_crypt_replacement()
+ # find handler & backend
+ handler, alt_backend = self._get_safe_crypt_handler_backend()
if not alt_backend:
- raise AssertionError("handler has no available backends!")
-
- # create subclass of handler, which we swap to an alternate backend.
- # NOTE: not switching original class's backend, since classes like bcrypt
- # run some checks when backend is set, that can cause recursion error
- # when orig backend is restored.
- alt_handler = type('%s_%s_wrapper' % (handler.name, alt_backend), (handler,), {})
- alt_handler._backend = None # ensure full backend load into subclass
- alt_handler.set_backend(alt_backend)
+ raise AssertionError("handler has no available alternate backends!")
- import passlib.utils as mod
+ # create subclass of handler, which we swap to an alternate backend
+ alt_handler = handler.using()
+ alt_handler.set_backend(alt_backend)
def crypt_stub(secret, hash):
- # with temporary_backend(alt_handler, alt_backend):
hash = alt_handler.genhash(secret, hash)
assert isinstance(hash, str)
return hash
- self.addCleanup(setattr, mod, "_crypt", mod._crypt)
- mod._crypt = crypt_stub
+ import passlib.utils as mod
+ self.patchAttr(mod, "_crypt", crypt_stub)
self.using_patched_crypt = True
+ @classmethod
+ def _get_skip_backend_reason(cls, backend):
+ """
+ make sure os_crypt backend is tested
+ when it's known os_crypt will be faked by _patch_safe_crypt()
+ """
+ assert backend == "os_crypt"
+ reason = super(OsCryptMixin, cls)._get_skip_backend_reason(backend)
+
+ from passlib.utils import has_crypt
+ if reason == cls.BACKEND_NOT_AVAILABLE and has_crypt:
+ if TEST_MODE("full") and cls._get_safe_crypt_handler_backend()[1]:
+ # in this case, _patch_safe_crypt() will monkeypatch os_crypt
+ # to use another backend, just so we can test os_crypt fully.
+ return None
+ else:
+ return "hash not supported by os crypt()"
+
+ return reason
+
#===================================================================
# custom tests
#===================================================================
+
+ # TODO: turn into decorator, and use mock library.
def _use_mock_crypt(self):
- """patch safe_crypt() so it returns mock value"""
+ """
+ patch passlib.utils.safe_crypt() so it returns mock value for duration of test.
+ returns function whose .return_value controls what's returned.
+ this defaults to None.
+ """
import passlib.utils as mod
- 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
+
+ def mock_crypt(secret, config):
+ # let 'test' string through so _load_os_crypt_backend() will still work
+ if secret == "test":
+ return mock_crypt.__wrapped__(secret, config)
+ else:
+ return mock_crypt.return_value
+
+ mock_crypt.__wrapped__ = mod._crypt
+ mock_crypt.return_value = None
+
+ self.patchAttr(mod, "_crypt", mock_crypt)
+
+ return mock_crypt
def test_80_faulty_crypt(self):
"""test with faulty crypt()"""
hash = self.get_sample_hash()[1]
exc_types = (AssertionError,)
- setter = self._use_mock_crypt()
+ mock_crypt = self._use_mock_crypt()
def test(value):
# set safe_crypt() to return specified value, and
# make sure assertion error is raised by handler.
- setter(value)
+ mock_crypt.return_value = 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)
@@ -2061,11 +2364,13 @@ class OsCryptMixin(HandlerCase):
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 self.find_crypt_replacement(fallback=True):
- # handler should have a fallback to use
+
+ # mock up safe_crypt to return None
+ mock_crypt = self._use_mock_crypt()
+ mock_crypt.return_value = None
+
+ if self.has_os_crypt_fallback:
+ # handler should have a fallback to use when os_crypt backend refuses to handle secret.
h1 = self.do_encrypt("stub")
h2 = self.do_genhash("stub", h1)
self.assertEqual(h2, h1)
@@ -2104,6 +2409,37 @@ class OsCryptMixin(HandlerCase):
"for %r" % (platform, self.handler.name))
#===================================================================
+ # fuzzy verified support -- add new verified that uses os crypt()
+ #===================================================================
+ def fuzz_verifier_crypt(self):
+ """test results against OS crypt()"""
+
+ # don't use this if we're faking safe_crypt (pointless test),
+ # or if handler is a wrapper (only original handler will be supported by os)
+ handler = self.handler
+ if self.using_patched_crypt or hasattr(handler, "wrapped"):
+ return None
+
+ # create a wrapper for fuzzy verified to use
+ from crypt import crypt
+
+ def check_crypt(secret, hash):
+ """stdlib-crypt"""
+ if not self.crypt_supports_variant(hash):
+ return "skip"
+ secret = to_native_str(secret, self.fuzz_password_encoding)
+ return crypt(secret, hash) == hash
+
+ return check_crypt
+
+ def crypt_supports_variant(self, hash):
+ """
+ fuzzy_verified_crypt() helper --
+ used to determine if os crypt() supports a particular hash variant.
+ """
+ return True
+
+ #===================================================================
# eoc
#===================================================================
@@ -2215,7 +2551,7 @@ class EncodingHandlerMixin(HandlerCase):
# so different encodings can be tested safely.
stock_passwords = [
u("test"),
- b("test"),
+ b"test",
u("\u00AC\u00BA"),
]
@@ -2234,8 +2570,9 @@ class EncodingHandlerMixin(HandlerCase):
#=============================================================================
# warnings helpers
#=============================================================================
-class reset_warnings(catch_warnings):
+class reset_warnings(warnings.catch_warnings):
"""catch_warnings() wrapper which clears warning registry & filters"""
+
def __init__(self, reset_filter="always", reset_registry=".*", **kwds):
super(reset_warnings, self).__init__(**kwds)
self._reset_filter = reset_filter
@@ -2254,37 +2591,34 @@ class reset_warnings(catch_warnings):
# that match the 'reset' pattern.
pattern = self._reset_registry
if pattern:
- orig = self._orig_registry = {}
- for name, mod in sys.modules.items():
- if pattern.match(name):
- reg = getattr(mod, "__warningregistry__", None)
- if reg:
- orig[name] = reg.copy()
- reg.clear()
+ backup = self._orig_registry = {}
+ for name, mod in list(sys.modules.items()):
+ if mod is None or not pattern.match(name):
+ continue
+ reg = getattr(mod, "__warningregistry__", None)
+ if reg:
+ backup[name] = reg.copy()
+ reg.clear()
return ret
def __exit__(self, *exc_info):
# restore warning registry for all modules
pattern = self._reset_registry
if pattern:
- # restore archived registry data
- orig = self._orig_registry
- for name, content in iteritems(orig):
- mod = sys.modules.get(name)
- if mod is None:
+ # restore registry backup, clearing all registry entries that we didn't archive
+ backup = self._orig_registry
+ for name, mod in list(sys.modules.items()):
+ if mod is None or not pattern.match(name):
continue
reg = getattr(mod, "__warningregistry__", None)
- if reg is None:
- setattr(mod, "__warningregistry__", content)
- else:
+ if reg:
reg.clear()
- reg.update(content)
- # clear all registry entries that we didn't archive
- for name, mod in sys.modules.items():
- if pattern.match(name) and name not in orig:
- reg = getattr(mod, "__warningregistry__", None)
- if reg:
- reg.clear()
+ orig = backup.get(name)
+ if orig:
+ if reg is None:
+ setattr(mod, "__warningregistry__", orig)
+ else:
+ reg.update(orig)
super(reset_warnings, self).__exit__(*exc_info)
#=============================================================================
diff --git a/passlib/totp.py b/passlib/totp.py
new file mode 100644
index 0000000..c51a165
--- /dev/null
+++ b/passlib/totp.py
@@ -0,0 +1,2182 @@
+"""passlib.totp -- TOTP / RFC6238 / Google Authenticator utilities."""
+#=============================================================================
+# imports
+#=============================================================================
+from __future__ import division
+from passlib.utils.compat import PY3
+# core
+import base64
+import calendar
+import json
+import logging; log = logging.getLogger(__name__)
+import struct
+import time as _time
+import re
+if PY3:
+ from urllib.parse import urlparse, parse_qsl, quote, unquote
+else:
+ from urllib import quote, unquote
+ from urlparse import urlparse, parse_qsl
+from warnings import warn
+# pkg
+from passlib import exc
+from passlib.utils import (to_unicode, to_bytes, consteq, memoized_property,
+ getrandbytes, rng, xor_bytes)
+from passlib.utils.compat import (u, unicode, bascii_to_str, int_types, num_types,
+ irange, byte_elem_value, UnicodeIO, PY26)
+from passlib.utils.pbkdf2 import get_prf, norm_hash_name, pbkdf2
+# local
+__all__ = [
+ # frontend classes
+ "TOTP",
+ "HOTP",
+
+ # deserialization
+ "from_uri",
+ "from_string",
+
+ # internal helpers
+ "BaseOTP",
+]
+
+#=============================================================================
+# HACK: python2.6's urlparse() won't parse query strings unless the url scheme
+# is one of the schemes in the urlparse.uses_query list. 2.7 abandoned
+# this, and parses query if present, regardless of the scheme.
+# as a workaround for py2.6, we add "otpauth" to the known list.
+#=============================================================================
+if PY26:
+ from urlparse import uses_query
+ if "otpauth" not in uses_query:
+ uses_query.append("otpauth")
+ log.debug("registered 'otpauth' scheme with urlparse.uses_query")
+ del uses_query
+
+#=============================================================================
+# internal helpers
+#=============================================================================
+class _SequenceMixin(object):
+ """
+ helper which lets result object act like a fixed-length sequence.
+ subclass just needs to provide :meth:`_as_tuple()`.
+ """
+ def _as_tuple(self):
+ raise NotImplemented("implement in subclass")
+
+ def __repr__(self):
+ return repr(self._as_tuple())
+
+ def __getitem__(self, idx):
+ return self._as_tuple()[idx]
+
+ def __iter__(self):
+ return iter(self._as_tuple())
+
+ def __len__(self):
+ return len(self._as_tuple())
+
+ def __eq__(self, other):
+ return self._as_tuple() == other
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+#-----------------------------------------------------------------------------
+# token parsing / rendering helpers
+#-----------------------------------------------------------------------------
+
+#: regex used to clean whitespace from tokens & keys
+_clean_re = re.compile(u("\s|[-=]"), re.U)
+
+_chunk_sizes = [4,6,5]
+
+def _get_group_size(klen):
+ """
+ helper for group_string() --
+ calculates optimal size of group for given string size.
+ """
+ # look for exact divisor
+ for size in _chunk_sizes:
+ if not klen % size:
+ return size
+ # fallback to divisor with largest remainder
+ # (so chunks are as close to even as possible)
+ best = _chunk_sizes[0]
+ rem = 0
+ for size in _chunk_sizes:
+ if klen % size > rem:
+ best = size
+ rem = klen % size
+ return best
+
+def group_string(value, sep="-"):
+ """
+ reformat string into (roughly) evenly-sized groups, separated by **sep**.
+ useful for making tokens & keys easier to read by humans.
+ """
+ klen = len(value)
+ size = _get_group_size(klen)
+ return sep.join(value[o:o+size] for o in irange(0, klen, size))
+
+#-----------------------------------------------------------------------------
+# encoding helpers
+#-----------------------------------------------------------------------------
+
+def b32encode(key):
+ """
+ wrapper around :func:`base64.b32encode` which strips padding,
+ and returns a native string.
+ """
+ # NOTE: using upper case by default here, since base32 has less ambiguity
+ # in that case ('i & l' are visually more similar than 'I & L')
+ return bascii_to_str(base64.b32encode(key).rstrip(b"="))
+
+def b32decode(key):
+ """
+ wrapper around :func:`base64.b32decode`
+ which handles common mistyped chars, and inserts padding.
+ """
+ if isinstance(key, unicode):
+ key = key.encode("ascii")
+ # XXX: could correct '1' -> 'I', but could be a mistyped lower-case 'l', so leaving it alone.
+ key = key.replace(b"8", b"B") # replace commonly mistyped char
+ key = key.replace(b"0", b"O") # ditto
+ pad = -len(key) % 8 # pad things so final string is multiple of 8
+ return base64.b32decode(key + b"=" * pad, True)
+
+def _decode_bytes(key, format):
+ """
+ internal _BaseOTP() helper --
+ decodes key according to specified format.
+ """
+ if format == "raw":
+ if not isinstance(key, bytes):
+ raise exc.ExpectedTypeError(key, "bytes", "key")
+ return key
+ # for encoded data, key must be either unicode or ascii-encoded bytes,
+ # and must contain a hex or base32 string.
+ key = to_unicode(key, param="key")
+ key = _clean_re.sub("", key).encode("utf-8") # strip whitespace & hypens
+ if format == "hex" or format == "base16":
+ return base64.b16decode(key.upper())
+ elif format == "base32":
+ return b32decode(key)
+ else:
+ raise ValueError("unknown byte-encoding format: %r" % (format,))
+
+#-----------------------------------------------------------------------------
+# encryption helpers -- used by to_json() / from_json() methods
+#-----------------------------------------------------------------------------
+
+#: default salt size for encrypt_key() output
+ENCRYPT_SALT_SIZE = 12
+
+#: default cost (log2 of pbkdf2 rounds) for encrypt_key() output
+ENCRYPT_COST = 13
+
+def _raw_encrypt_key(key, password, salt, cost):
+ """
+ internal helper for encrypt_key() & decrypt_key() --
+ runs password through pbkdf2-hmac-sha256,
+ and XORs key with the resulting bytes.
+ """
+ # NOTE: have to have a unique salt here, otherwise attacker can use known plaintext attack
+ # to figure out 'data', and trivially decrypt other keys in the database,
+ # (all without needing 'password')
+ assert isinstance(key, bytes)
+ password = to_bytes(password, param="password")
+ data = pbkdf2(password, salt=salt, rounds=(1<<cost),
+ keylen=len(key), prf="hmac-sha256")
+ return xor_bytes(key, data)
+
+def encrypt_key(key, password, cost=None):
+ """
+ Helper used to encrypt TOTP keys for storage.
+
+ Since keys will typically be small (<= 32 bytes), this just runs the password
+ through PBKDF2-HMAC-SHA256, and XORs the result with the key (rather than using AES).
+ A version number and the cost parameter value is prepended.
+
+ :arg key: raw key as bytes
+ :arg password: password for encryption, as bytes
+ :param cost: encryption will use ``2**cost`` pbkdf2 rounds.
+ :returns:
+ encrypted key, using format :samp:`{version}-{cost}-{salt}-{data}`.
+ ``version`` and ``cost`` are hex integers, ``salt`` and ``data`` are base32 encoded bytes.
+ """
+ if not key:
+ raise ValueError("no key provided")
+ if cost is None:
+ cost = ENCRYPT_COST
+ salt = getrandbytes(rng, ENCRYPT_SALT_SIZE)
+ rawenckey = _raw_encrypt_key(key, password, salt, cost)
+ # NOTE: * no checksum, to save space and to make things harder on attacker
+ # * considered storing as binary string, and then encoding, but no real space savings,
+ # and this is more transparent about it's structure.
+ # * since this is internal, could use base64 here, but keeping w/ base32 to be
+ # consistent w/ OTP, and that would only save ~4 bytes anyways.
+ return "%X-%X-%s-%s" % (1, cost, b32encode(salt), b32encode(rawenckey))
+
+def decrypt_key(enckey, password):
+ """
+ decrypt key format generated by :func:`encrypt_key`.
+ """
+ def _malformed_error():
+ return ValueError("malformed encrypt_key() data")
+ enckey = to_unicode(enckey, param="enckey")
+ try:
+ ver, tail = enckey.split("-", 1)
+ except ValueError:
+ raise _malformed_error()
+ if ver != "1":
+ raise ValueError("unknown encrypt_key() version")
+ try:
+ cost, salt, enckey = tail.split("-")
+ except ValueError:
+ raise _malformed_error()
+ cost = int(cost, 16)
+ try:
+ salt = b32decode(salt)
+ rawenckey = b32decode(enckey)
+ except (ValueError, TypeError) as err:
+ if str(err).lower() in ["incorrect padding", "non-base32 digit found"]:
+ raise _malformed_error()
+ raise
+ return _raw_encrypt_key(rawenckey, password, salt, cost)
+
+#-----------------------------------------------------------------------------
+# offset / clock drift helpers
+#-----------------------------------------------------------------------------
+
+#: default offset preferred by suggest_offset()
+#: attempts to account for time taken for user to enter token + transmission delay.
+#: value is avg of 'history1' sample in the unittests.
+DEFAULT_OFFSET = 0
+
+def suggest_offset(history, period=30, target=None, default=None):
+ """
+ Given a history of previous verification offsets,
+ calculate offset that should be used for specified timestamp.
+ This is used by :meth:`verify` and :meth:`verify_next`.
+
+ :param history:
+ List of 0+ ``(timestamp, counter_offset)`` entries.
+
+ :param period:
+ Counter period in seconds (defaults to 30).
+
+ :param target:
+ Timestamp that resulting offset should target (defaults to the current time).
+
+ :param default:
+ Default offset if there are no history entries;
+ also used as starting seed. for calculations.
+
+ :returns:
+ Suggested offset to use when verifying at specified time.
+ """
+ # NOTE: ``target`` param currently unused, reserved for future algorithm
+ # which might attempt to utilize timestamp data to account for client clock drift.
+
+ # XXX: This function could use a lot of improvement.
+ #
+ # The Problem
+ # -----------
+ # The problem this function is trying to solve is to find an estimate for the client
+ # offset at time <target>, given a list of known (time, counter_offset) values from previously
+ # successful authentications. An ideal solution would use some method (e.g. linear regression)
+ # to estimate the client clock drift and skew, and correctly predict the offset
+ # we need for the target timestamp.
+ #
+ # However, all we know for each (time, counter_offset) pair is that the actual offset at that
+ # point in time lies somewhere in the half-closed interval:
+ # ``[counter_start - time, counter_end - time)``
+ # ... which can be reduced to:
+ # ``[min_offset, min_offset + period)``
+ # .. where min_offset is:
+ # ``counter = time // period``,
+ # ``min_offset = (counter + counter_offset) * period - time``
+ #
+ # Further complicating things, the actual offset is not just a function of the client clock skew,
+ # but also includes a random amount of transmission delay (including time taken by the user
+ # to enter the token).
+ #
+ # Thus any proper solution would need to predict a best fit line across a set of intervals,
+ # not just datapoints, minimizing drift, while ignoring outliers.
+ #
+ # Current Algorithm
+ # -----------------
+ # For now, mostly punting on this problem.
+ # Current code just takes the average & stddev of the intervals,
+ # and returns value in interval ``avg +- sigma`` which is nearest ``default``.
+
+ # use default offset
+ if default is None:
+ default = DEFAULT_OFFSET
+
+ # fallback for empty list
+ if not history:
+ return default
+
+ # helpers
+ def calc_min_offset(time, counter_offset):
+ return counter_offset * period - divmod(int(time), period)[1]
+
+ # convert to list of min_offset values -- more useful for current algorithm
+ half_period = period // 2
+ min_offsets = [calc_min_offset(time, diff) for time, diff in history]
+ ##log.debug("suggest_offsets(): midpoints=%r",
+ ## [min_offset+half_period for min_offset in min_offsets])
+
+ # calc average & stddev of min_offset values
+ hsize = len(history)
+ avg = sum(min_offsets) // hsize
+ if hsize > 2:
+ _total = sum((min_offset - avg) ** 2 for min_offset in min_offsets)
+ sigma = int((_total // (hsize - 1)) ** 0.5)
+ else:
+ # too few samples for stddev to be reliable
+ # (*need* at least 2, but < 3 seems to fluctuate too much for this purpose)
+ sigma = half_period
+
+ # add half period so that avg of min_offset is now
+ # avg midpoint of the [min_offset, max_offset) intervals
+ avg += half_period
+
+ # keep result within 1/2 of sigma or interval size, whichever is smaller.
+ # using full sigma or interval size seems to add too much variability in output.
+ bounds = min(sigma, half_period)//2
+
+ # use default if within bounds of avg,
+ # otherwise use whichever of ``avg +- bounds`` is closest to default.
+ ##log.debug("suggest_offsets(): avg=%r, radius=%r, sigma=%r, bound=%r",
+ ## avg, half_period, sigma, bounds)
+ if abs(default - avg) <= bounds:
+ return default
+ elif avg < default:
+ return avg + bounds
+ else:
+ return avg - bounds
+
+# def _debug_suggested_offset(data, default=None):
+# """dev helper for debugging suggested_offset() behavior"""
+# from crowbar.math import analyze_values
+# for window in range(1, len(data)+1):
+# result = []
+# offset = default # simulate offset being carried through a rolling window
+# for idx in range(len(data)-window+1):
+# offset = suggest_offset(data[idx:idx+window], default=offset)
+# result.append(offset)
+# stats = analyze_values(result)
+# print "{:2d} {:2.0f} {:2.2f} {!r}".format(window, stats.mean, stats.stdev, result)
+
+#=============================================================================
+# common code shared by TOTP & HOTP
+#=============================================================================
+class BaseOTP(object):
+ """
+ Base class for generating and verifying OTP codes.
+
+ .. rst-class:: inline-title
+
+ .. note::
+
+ **This class shouldn't be used directly.**
+ It's here to provide & document common functionality
+ shared by the :class:`TOTP` and :class:`HOTP` classes.
+ See those classes for usage instructions.
+
+ .. _baseotp-constructor-options:
+
+ Constructor Options
+ ===================
+ Both the :class:`TOTP` and :class:`HOTP` classes accept the following common options
+ (only **key** and **format** may be specified as positional arguments).
+
+ :arg str key:
+ The secret key to use. By default, should be encoded as
+ a base32 string (see **format** for other encodings).
+ (Exactly one of **key** or ``new=True`` must be specified)
+
+ :arg str format:
+ The encoding used by the **key** parameter. May be one of:
+ ``"base32"`` (base32-encoded string),
+ ``"hex"`` (hexadecimal string), or ``"raw"`` (raw bytes).
+ Defaults to ``"base32"``.
+
+ :param bool new:
+ If ``True``, a new key will be generated using :class:`random.SystemRandom`.
+ By default, the generated key will match the digest size of the selected **alg**.
+ (Exactly one ``new=True`` or **key** must be specified)
+
+ :param str label:
+ Label to associate with this token when generating a URI.
+ Displayed to user by most OTP client applications (e.g. Google Authenticator),
+ and typically has format such as ``"John Smith"`` or ``"jsmith@webservice.example.org"``.
+ Defaults to ``None``.
+ See :meth:`to_uri` for details.
+
+ :param str issuer:
+ String identifying the token issuer (e.g. the domain name of your service).
+ Used internally by some OTP client applications (e.g. Google Authenticator) to distinguish entries
+ which otherwise have the same label.
+ Optional but strongly recommended if you're rendering to a URI.
+ Defaults to ``None``.
+ See :meth:`to_uri` for details.
+
+ :param int size:
+ Number of bytes when generating new keys. Defaults to size of hash algorithm (e.g. 20 for SHA1).
+
+ .. warning::
+
+ Overriding the default values for ``digits`` or ``alg`` (below) may
+ cause problems with some OTP client programs (such as Google Authenticator),
+ which may have these defaults hardcoded.
+
+ :param int digits:
+ The number of digits in the generated / accepted tokens. Defaults to ``6``.
+ Must be in range [6 .. 10].
+
+ .. rst-class:: inline-title
+ .. caution::
+ Due to a limitation of the HOTP algorithm, the 10th digit can only take on values 0 .. 2,
+ and thus offers very little extra security.
+
+ :param str alg:
+ Name of hash algorithm to use. Defaults to ``"sha1"``.
+ ``"sha256"`` and ``"sha512"`` are also accepted, per :rfc:`6238`.
+
+ .. _baseotp-configuration-attributes:
+
+ Configuration Attributes
+ ========================
+ All the OTP objects offer the following attributes,
+ which correspond to the constructor options (above).
+ Most of this information will be serialized by :meth:`to_uri` and :meth:`to_string`:
+
+ .. autoattribute:: key
+ .. autoattribute:: hex_key
+ .. autoattribute:: base32_key
+ .. autoattribute:: label
+ .. autoattribute:: issuer
+ .. autoattribute:: digits
+ .. autoattribute:: alg
+
+ .. _baseotp-client-provisioning:
+
+ Client Provisioning (URIs & QRCodes)
+ ====================================
+ The configuration of any OTP object can be encoded into a URI [#uriformat]_,
+ suitable for configuring an OTP client such as Google Authenticator.
+
+ .. automethod:: to_uri
+ .. automethod:: from_uri
+ .. automethod:: pretty_key
+
+ .. _baseotp-serialization:
+
+ Serialization
+ =============
+ While :class:`TOTP` and :class:`HOTP` instances can be used statelessly
+ to calculate token values, they can also be used in a persistent
+ manner, to handle tracking of previously used tokens, etc. In this case,
+ they will need to be serialized to / from external storage, which
+ can be performed with the following methods:
+
+ .. automethod:: to_string
+ .. automethod:: from_string
+
+ .. attribute:: dirty
+
+ boolean flag set by all BaseOTP subclass methods which modify the internal state.
+ if true, then something has changed in the object since it was created / loaded
+ via :meth:`from_string`, and needs re-persisting via :meth:`to_string`.
+ After which, your application may clear the flag, or discard the object, as appropriate.
+
+ ..
+ Undocumented Helper Methods
+ ===========================
+
+ .. automethod:: normalize_token
+ """
+ #=============================================================================
+ # class attrs
+ #=============================================================================
+
+ #: otpauth uri type that subclass implements ('totp' or 'hotp')
+ #: (used by uri & serialization code)
+ type = None
+
+ #: minimum number of bytes to allow in key, enforced by passlib.
+ # XXX: see if spec says anything relevant to this.
+ _min_key_size = 10
+
+ #: dict used by from_uri() to lookup subclass based on otpauth type
+ _type_map = {}
+
+ #: minimum & current serialization version (may be set independently by subclasses)
+ min_json_version = json_version = 1
+
+ #=============================================================================
+ # instance attrs
+ #=============================================================================
+
+ #: secret key as raw :class:`!bytes`
+ key = None
+
+ #: copy of original encrypted key,
+ #: used by to_string() to re-serialize w/ original password.
+ _enckey = None
+
+ #: number of digits in the generated tokens.
+ digits = 6
+
+ #: name of hash algorithm in use (e.g. ``"sha1"``)
+ alg = "sha1"
+
+ #: default label for :meth:`to_uri`
+ label = None
+
+ #: default issuer for :meth:`to_uri`
+ issuer = None
+
+ #---------------------------------------------------------------------------
+ # state attrs
+ #---------------------------------------------------------------------------
+
+ #: flag set if internal state is modified
+ dirty = False
+
+ #=============================================================================
+ # init
+ #=============================================================================
+ def __init__(self, key=None, format="base32",
+ # keyword only...
+ new=False, digits=None, alg=None, size=None,
+ label=None, issuer=None, dirty=False, password=None,
+ rng=rng, # mainly for unittesting
+ **kwds):
+ if type(self) is BaseOTP:
+ raise RuntimeError("BaseOTP() shouldn't be invoked directly -- use TOTP() or HOTP() instead")
+ super(BaseOTP, self).__init__(**kwds)
+ self.dirty = dirty
+
+ # validate & normalize alg
+ self.alg = norm_hash_name(alg or self.alg)
+ # XXX: could use get_keyed_prf() instead
+ digest_size = get_prf("hmac-" + self.alg)[1]
+ if digest_size < 4:
+ raise RuntimeError("%r hash digest too small" % alg)
+
+ # parse or generate new key
+ if new:
+ # generate new key
+ if key:
+ raise TypeError("'key' and 'new' are mutually exclusive")
+ if size is None:
+ # default to digest size, per RFC 6238 Section 5.1
+ size = digest_size
+ elif size > digest_size:
+ # not forbidden by spec, but would just be wasted bytes. maybe just warn about this?
+ raise ValueError("'size' should be less than digest size (%d)" % digest_size)
+ self.key = getrandbytes(rng, size)
+ elif not key:
+ raise TypeError("must specify either an existing 'key', or 'new=True'")
+ elif format == "encrypted":
+ # use existing-but-encrypted key, and store copy for to_string()
+ if not password:
+ raise ValueError("cannot load encrypted key without password")
+ self._enckey = key
+ self.key = decrypt_key(key, password)
+ else:
+ # use existing plain key
+ self.key = _decode_bytes(key, format)
+ if password and not self._enckey:
+ # pre-encrypt copy for to_string().
+ # alternately, we could keep password hanging around instead.
+ self._enckey = encrypt_key(self.key, password)
+ if len(self.key) < self._min_key_size:
+ # only making this fatal for new=True,
+ # so that existing (but ridiculously small) keys can still be used.
+ msg = "for security purposes, secret key must be >= %d bytes" % self._min_key_size
+ if new:
+ raise ValueError(msg)
+ else:
+ warn(msg, exc.PasslibSecurityWarning, stacklevel=1)
+
+ # validate digits
+ if digits is None:
+ digits = self.digits
+ if not isinstance(digits, int_types):
+ raise TypeError("digits must be an integer, not a %r" % type(digits))
+ if digits < 6 or digits > 10:
+ raise ValueError("digits must in range(6,11)")
+ self.digits = digits
+
+ # validate label
+ if label:
+ self._check_label(label)
+ self.label = label
+
+ # validate issuer
+ if issuer:
+ self._check_issuer(issuer)
+ self.issuer = issuer
+
+ def _check_serial(self, value, param, minval=0):
+ """
+ check that serial value (e.g. 'counter') is non-negative integer
+ """
+ if not isinstance(value, int_types):
+ raise exc.ExpectedTypeError(value, "int", param)
+ if value < minval:
+ raise ValueError("%s must be >= %d" % (param, minval))
+
+ def _check_label(self, label):
+ """
+ check that label doesn't contain chars forbidden by KeyURI spec
+ """
+ if label and ":" in label:
+ raise ValueError("label may not contain ':'")
+
+ def _check_issuer(self, issuer):
+ """
+ check that issuer doesn't contain chars forbidden by KeyURI spec
+ """
+ if issuer and ":" in issuer:
+ raise ValueError("issuer may not contain ':'")
+
+ #=============================================================================
+ # key helpers
+ #=============================================================================
+ @property
+ def hex_key(self):
+ """
+ secret key encoded as hexadecimal string
+ """
+ return bascii_to_str(base64.b16encode(self.key)).lower()
+
+ @property
+ def base32_key(self):
+ """
+ secret key encoded as base32 string
+ """
+ return b32encode(self.key)
+
+ def pretty_key(self, format="base32", sep="-"):
+ """
+ pretty-print the secret key.
+
+ This is mainly useful for situations where the user cannot get the qrcode to work,
+ and must enter the key manually into their TOTP client. It tries to format
+ the key in a manner that is easier for humans to read.
+
+ :param format:
+ format to output secret key. ``"hex"`` and ``"base32"`` are both accepted.
+
+ :param sep:
+ separator to insert to break up key visually.
+ can be any of ``"-"`` (the default), ``" "``, or ``False`` (no separator).
+
+ :return:
+ key as native string.
+
+ Usage example::
+
+ >>> t = TOTP('s3jdvb7qd2r7jpxx')
+ >>> t.pretty_key()
+ 'S3JD-VB7Q-D2R7-JPXX'
+ """
+ if format == "hex" or format == "base16":
+ key = self.hex_key
+ elif format == "base32":
+ key = self.base32_key
+ else:
+ raise ValueError("unknown byte-encoding format: %r" % (format,))
+ if sep:
+ key = group_string(key, sep)
+ return key
+
+ #=============================================================================
+ # token helpers
+ #=============================================================================
+
+ @memoized_property
+ def _prf_info(self):
+ return get_prf("hmac-" + self.alg)
+
+ def _generate(self, counter):
+ """
+ implementation of lowlevel HOTP generation algorithm,
+ shared by both TOTP and HOTP classes.
+
+ :arg counter: HOTP counter, as non-negative integer
+ :returns: token as unicode string
+ """
+ # generate digest
+ prf, digest_size = self._prf_info
+ assert isinstance(counter, int_types), "counter must be integer"
+ digest = prf(self.key, struct.pack(">Q", counter))
+ assert len(digest) == digest_size, "digest_size: sanity check failed"
+
+ # derive 31-bit token value
+ assert digest_size >= 20, "digest_size: sanity check 2 failed" # otherwise 0xF+4 will run off end of hash.
+ offset = byte_elem_value(digest[-1]) & 0xF
+ value = struct.unpack(">I", digest[offset:offset+4])[0] & 0x7fffffff
+
+ # render to decimal string, return last <digits> chars
+ # NOTE: the 10'th digit is not as secure, as it can only take on values 0-2, not 0-9,
+ # due to 31-bit mask on int ">I". But some servers / clients use it :|
+ # if 31-bit mask removed (which breaks spec), would only get values 0-4.
+ digits = self.digits
+ assert 0 < digits < 11, "digits: sanity check failed"
+ return (u("%0*d") % (digits, value))[-digits:]
+
+ def normalize_token(self, token):
+ """
+ normalize OTP token representation:
+ strips whitespace, converts integers to zero-padded string,
+ validates token content & number of digits.
+
+ :arg token:
+ token as ascii bytes, unicode, or an integer.
+
+ :returns:
+ token as unicode string containing only digits 0-9.
+
+ :raises ValueError:
+ if token has wrong number of digits, or contains non-numeric characters.
+ """
+ digits = self.digits
+ if isinstance(token, int_types):
+ token = u("%0*d") % (digits, token)
+ else:
+ token = to_unicode(token, param="token")
+ token = _clean_re.sub(u(""), token)
+ if not token.isdigit():
+ raise ValueError("Invalid token: must contain only the digits 0-9")
+ if len(token) != digits:
+ raise ValueError("Invalid token: expected %d digits, got %d" %
+ (digits, len(token)))
+ return token
+
+ def _find_match(self, token, start, end, expected=None):
+ """
+ helper for verify() implementations --
+ returns counter value within specified range that matches token.
+
+ :arg token:
+ token value to match (will be normalized internally)
+
+ :arg start:
+ starting counter value to check
+
+ :arg end:
+ check up to (but not including) this counter value
+
+ :arg expected:
+ optional expected value where search should start,
+ to help speed up searches.
+
+ :returns:
+ ``(valid, match)`` where ``match`` is non-negative counter value that matched
+ (or ``0`` if no match).
+ """
+ token = self.normalize_token(token)
+ if start < 0:
+ start = 0
+ if end <= start:
+ return False, 0
+ generate = self._generate
+ if not (expected is None or expected < start) and consteq(token, generate(expected)):
+ return True, expected
+ # XXX: if (end - start) is very large (e.g. for resync purposes),
+ # could start with expected value, and work outward from there,
+ # alternately checking before & after it until match is found.
+ for counter in irange(start, end):
+ if consteq(token, generate(counter)):
+ return True, counter
+ return False, 0
+
+ #=============================================================================
+ # uri parsing
+ #=============================================================================
+ @classmethod
+ def from_uri(cls, uri):
+ """
+ create an OTP instance from a URI (such as returned by :meth:`to_uri`).
+
+ :returns:
+ :class:`TOTP` or :class:`HOTP` instance, as appropriate.
+
+ :raises ValueError:
+ if the uri cannot be parsed or contains errors.
+ """
+ # check for valid uri
+ uri = to_unicode(uri, param="uri").strip()
+ result = urlparse(uri)
+ if result.scheme != "otpauth":
+ raise cls._uri_error("wrong uri scheme")
+
+ # lookup factory to handle OTP type, and hand things off to it.
+ try:
+ subcls = cls._type_map[result.netloc]
+ except KeyError:
+ raise cls._uri_error("unknown OTP type")
+ return subcls._from_parsed_uri(result)
+
+ @classmethod
+ def _from_parsed_uri(cls, result):
+ """
+ internal from_uri() helper --
+ hands off the main work to this function, once the appropriate subclass
+ has been resolved.
+
+ :param result: a urlparse() instance
+ :returns: cls instance
+ """
+
+ # decode label from uri path
+ label = result.path
+ if label.startswith("/") and len(label) > 1:
+ label = unquote(label[1:])
+ else:
+ raise cls._uri_error("missing label")
+
+ # extract old-style issuer prefix
+ if ":" in label:
+ try:
+ issuer, label = label.split(":")
+ except ValueError: # too many ":"
+ raise cls._uri_error("malformed label")
+ else:
+ issuer = None
+ if label:
+ label = label.strip() or None
+
+ # parse query params
+ params = dict(label=label)
+ for k, v in parse_qsl(result.query):
+ if k in params:
+ raise cls._uri_error("duplicate parameter (%r)" % k)
+ params[k] = v
+
+ # synchronize issuer prefix w/ issuer param
+ if issuer:
+ if "issuer" not in params:
+ params['issuer'] = issuer
+ elif params['issuer'] != issuer:
+ raise cls._uri_error("conflicting issuer identifiers")
+
+ # convert query params to constructor kwds, and call constructor
+ return cls(**cls._adapt_uri_params(**params))
+
+ @classmethod
+ def _adapt_uri_params(cls, label=None, secret=None, issuer=None,
+ digits=None, algorithm=None,
+ **extra):
+ """
+ from_uri() helper --
+ converts uri params into constructor args.
+ this handles the parameters common to TOTP & HOTP.
+ """
+ assert label, "from_uri() failed to provide label"
+ if not secret:
+ raise cls._uri_error("missing 'secret' parameter")
+ kwds = dict(label=label, issuer=issuer, key=secret, format="base32")
+ if digits:
+ kwds['digits'] = cls._uri_parse_int(digits, "digits")
+ if algorithm:
+ kwds['alg'] = algorithm
+ if extra:
+ # malicious uri, deviation from spec, or newer revision of spec?
+ # in either case, we issue warning and ignore extra params.
+ warn("%s: unexpected parameters encountered in otp uri: %r" %
+ (cls, extra), exc.PasslibRuntimeWarning)
+ return kwds
+
+ @classmethod
+ def _uri_error(cls, reason):
+ """uri parsing helper -- creates preformatted error message"""
+ prefix = cls.__name__ + ": " if cls.type else ""
+ return ValueError("%sInvalid otpauth uri: %s" % (prefix, reason))
+
+ @classmethod
+ def _uri_parse_int(cls, source, param):
+ """uri parsing helper -- int() wrapper"""
+ try:
+ return int(source)
+ except ValueError:
+ raise cls._uri_error("Malformed %r parameter" % param)
+
+ #=============================================================================
+ # uri rendering
+ #=============================================================================
+ def to_uri(self, label=None, issuer=None):
+ """
+ serialize key and configuration into a URI, per
+ Google Auth's `KeyUriFormat <http://code.google.com/p/google-authenticator/wiki/KeyUriFormat>`_.
+
+ :param str label:
+ Label to associate with this token when generating a URI.
+ Displayed to user by most OTP client applications (e.g. Google Authenticator),
+ and typically has format such as ``"John Smith"`` or ``"jsmith@webservice.example.org"``.
+
+ Defaults to **label** constructor argument. Must be provided in one or the other location.
+ May not contain ``:``.
+
+ :param str issuer:
+ String identifying the token issuer (e.g. the domain or canonical name of your service).
+ Optional but strongly recommended if you're rendering to a URI.
+ Used internally by some OTP client applications (e.g. Google Authenticator) to distinguish entries
+ which otherwise have the same label.
+
+ Defaults to **issuer** constructor argument, or ``None``.
+ May not contain ``:``.
+
+ :returns:
+ all the configuration information for this OTP token generator,
+ encoded into a URI.
+
+ :raises ValueError:
+ * if a label was not provided either as an argument, or in the constructor.
+ * if the label or issuer contains invalid characters.
+
+ These URIs are frequently converted to a QRCode for transferring
+ to a TOTP client application such as Google Auth. This can easily be done
+ using external libraries such as `pyqrcode <https://pypi.python.org/pypi/PyQRCode>`_
+ or `qrcode <https://pypi.python.org/pypi/qrcode>`_.
+ Usage example::
+
+ >>> from passlib.totp import TOTP
+ >>> tp = TOTP('s3jdvb7qd2r7jpxx')
+ >>> uri = tp.to_uri("user@example.org", "myservice.another-example.org")
+ >>> uri
+ 'otpauth://totp/user@example.org?secret=S3JDVB7QD2R7JPXX&issuer=myservice.another-example.org'
+
+ >>> # for example, the following uses PyQRCode
+ >>> # to print the uri directly on an ANSI terminal as a qrcode:
+ >>> import pyqrcode
+ >>> pyqrcode.create(uri).terminal()
+ (... output omitted ...)
+
+ """
+ # encode label
+ if label is None:
+ label = self.label
+ if not label:
+ raise ValueError("a label must be specified as argument, or in the constructor")
+ self._check_label(label)
+ # NOTE: reference examples in spec seem to indicate the '@' in a label
+ # shouldn't be escaped, though spec doesn't explicitly address this.
+ # XXX: is '/' ok to leave unencoded?
+ label = quote(label, '@')
+
+ # encode query parameters
+ args = self._to_uri_params()
+ if issuer is None:
+ issuer = self.issuer
+ if issuer:
+ self._check_issuer(issuer)
+ args.append(("issuer", issuer))
+ # NOTE: not using urllib.urlencode() because it encodes ' ' as '+';
+ # but spec says to use '%20', and not sure how fragile
+ # the various totp clients' parsers are.
+ argstr = u("&").join(u("%s=%s") % (key, quote(value, ''))
+ for key, value in args)
+ assert argstr, "argstr should never be empty"
+
+ # render uri
+ return u("otpauth://%s/%s?%s") % (self.type, label, argstr)
+
+ def _to_uri_params(self):
+ """return list of (key, param) entries for URI"""
+ args = [("secret", self.base32_key)]
+ if self.alg != "sha1":
+ args.append(("algorithm", self.alg.upper()))
+ if self.digits != 6:
+ args.append(("digits", str(self.digits)))
+ return args
+
+ #=============================================================================
+ # json parsing
+ #=============================================================================
+ @classmethod
+ def from_string(cls, data, password=None):
+ """
+ Load / create an OTP object from a serialized json string
+ (as generated by :meth:`to_string`).
+
+ :arg data:
+ serialized output from :meth:`to_string`, as unicode or ascii bytes.
+
+ :param password:
+ if the key was encrypted with a password, this must be provided.
+ otherwise this option is ignored.
+
+ :returns:
+ a :class:`TOTP` or :class:`HOTP` instance, as appropriate.
+
+ :raises ValueError:
+ If the key has been encrypted with a password, but none was provided;
+ or if the string cannot be recognized, parsed, or decoded.
+ """
+ if data.startswith("otpauth://"):
+ return cls.from_uri(data)
+ kwds = json.loads(data)
+ if not (isinstance(kwds, dict) and "type" in kwds):
+ raise cls._json_error("unrecognized json data")
+ try:
+ subcls = cls._type_map[kwds.pop('type')]
+ except KeyError:
+ raise cls._json_error("unknown OTP type")
+ ver = kwds.pop("v", None)
+ if not ver or ver < cls.min_json_version or ver > cls.json_version:
+ raise cls._json_error("missing/unsupported version (%r)" % (ver,))
+ # go ahead and mark as dirty (needs re-saving) if the version is too old
+ kwds['dirty'] = (ver != cls.json_version)
+ if password:
+ # send password to constructor even if not encrypting,
+ # so _enckey will get populated for to_string().
+ kwds['password'] = password
+ if 'enckey' in kwds:
+ # handing encrypted key off to constructor, which handles the
+ # decryption. this lets it get ahold of (and store) the original
+ # encrypted key, so if to_string() is called again, the encrypted
+ # key can be re-used.
+ assert 'key' not in kwds # shouldn't be present w/ enckey
+ assert 'format' not in kwds # shouldn't be present w/ enckey
+ kwds.update(
+ key = kwds.pop("enckey"),
+ format = "encrypted",
+ )
+ elif 'key' in kwds:
+ assert 'format' not in kwds # shouldn't be present, base32 assumed
+ else:
+ raise cls._json_error("missing enckey / key")
+ return subcls(**subcls._from_json(ver, **kwds))
+
+ @classmethod
+ def _from_json(cls, version, **kwds):
+ # default json format is just serialization of constructor kwds.
+ return kwds
+
+ @classmethod
+ def _json_error(cls, reason):
+ """json parsing helper -- creates preformatted error message"""
+ prefix = cls.__name__ + ": " if cls.type else ""
+ return ValueError("%sInvalid otp json string: %s" % (prefix, reason))
+
+ #=============================================================================
+ # json rendering
+ #=============================================================================
+ def to_string(self, password=None, cost=None):
+ """
+ serialize configuration & internal state to a json string,
+ mainly for persisting client-specific state in a database.
+
+ :param password:
+ Optional password which will be used to encrypt the secret key.
+
+ *(The key is encrypted using PBKDF2-HMAC-SHA256, see the source
+ of the* :func:`encrypt_key` *function for details)*.
+
+ If the TOTP object had a password provided to the constructor,
+ to or :meth:`from_string`, you can set ``password=True`` here
+ to simply re-use the previously encrypted secret key.
+
+ :param cost:
+ Optional time-cost factor for key encryption.
+ This value corresponds to log2() of the number of PBKDF2
+ rounds used, which currently defaults to 13.
+
+ :returns:
+ string containing the full state of the OTP object,
+ serialized to an internal format (roughly, a JSON serialization
+ of the constructor options).
+
+ .. warning::
+
+ The **password** should be kept in a secure location by your application,
+ and contain a large amount of entropy (to prevent brute-force guessing).
+ Since the encrypt/decrypt cycle is expected to be required
+ to (de-)serialize TOTP instances every time a user logs in,
+ the default work-factor (``cost``) is kept relatively low.
+ """
+ kwds = self._to_json()
+ assert 'v' in kwds
+ if password:
+ # XXX: support a password_id so they can be migrated?
+ # e.g. make this work with peppers in CryptContext?
+ if password is True:
+ if not self._enckey:
+ raise RuntimeError("no password provided to constructor or to_string()")
+ kwds['enckey'] = self._enckey
+ else:
+ kwds['enckey'] = encrypt_key(self.key, password, cost=cost)
+ else:
+ kwds['key'] = self.base32_key
+ return json.dumps(kwds, sort_keys=True, separators=(",",":"))
+
+ def _to_json(self):
+ # NOTE: 'key' added by to_json() wrapper
+ kwds = dict(type=self.type, v=self.json_version)
+ if self.alg != "sha1":
+ kwds['alg'] = self.alg
+ if self.digits != 6:
+ kwds['digits'] = self.digits
+ if self.label:
+ kwds['label'] = self.label
+ if self.issuer:
+ kwds['issuer'] = self.issuer
+ return kwds
+
+ #=============================================================================
+ # eoc
+ #=============================================================================
+
+#=============================================================================
+# HOTP helper
+#=============================================================================
+class HotpMatch(_SequenceMixin):
+ """
+ Object returned by :meth:`HOTP.verify`.
+ It can be treated as a tuple of ``(valid, counter)``,
+ or accessed via the following attributes:
+
+ .. autoattribute:: valid
+ .. autoattribute:: counter
+ .. autoattribute:: counter_offset
+ """
+ #: bool flag indicating whether token matched
+ #: (also reflected as object's boolean value)
+ valid = False
+
+ #: new HOTP counter value (1 + matched counter value);
+ #: or previous counter value if there was no match.
+ counter = 0
+
+ #: how many counter values were skipped between expected counter value to matched counter value
+ #: (0 if there was no match).
+ counter_offset = 0
+
+ def __init__(self, valid, counter, counter_offset):
+ self.valid = valid
+ self.counter = counter
+ self.counter_offset = counter_offset
+
+ def _as_tuple(self):
+ return (self.valid, self.counter)
+
+ def __nonzero__(self):
+ return self.valid
+
+ __bool__ = __nonzero__ # py2 compat
+
+class HOTP(BaseOTP):
+ """Helper for generating and verifying HOTP codes.
+
+ Given a secret key and set of configuration options, this object
+ offers methods for token generation, token validation, and serialization.
+ It can also be used to track important persistent HOTP state,
+ such as the next counter value.
+
+ Constructor Options
+ ===================
+ In addition to the :ref:`BaseOTP Constructor Options <baseotp-constructor-options>`,
+ this class accepts the following extra parameters:
+
+ :param int counter:
+ The initial counter value to use when generating new tokens via :meth:`generate_next()`,
+ or when verifying them via :meth:`verify_next()`.
+
+ Client-Side Token Generation
+ ============================
+ .. automethod:: generate
+ .. automethod:: generate_next
+
+ Server-Side Token Verification
+ ==============================
+ .. automethod:: verify
+ .. automethod:: verify_next
+
+ .. todo::
+
+ Offer a resynchronization primitive which allows user to provide a large number of sequential tokens
+ taken from a pre-determined counter range (google's "emergency recovery code" style);
+ or at current counter, but with a much larger window (as referenced in the RFC).
+
+ Provisioning & Serialization
+ ============================
+ The shared provisioning & serialization methods for the :class:`!TOTP` and :class:`!HOTP` classes
+ are documented under:
+
+ * :ref:`BaseOTP Client Provisioning <baseotp-client-provisioning>`
+ * :ref:`BaseOTP Serialization <baseotp-serialization>`
+
+
+ Internal State Attributes
+ =========================
+ The following attributes are used to track the internal state of this generator,
+ and will be included in the output of :meth:`to_string`:
+
+ .. autoattribute:: counter
+
+ .. attribute:: dirty
+
+ boolean flag set by :meth:`generate_next` and :meth:`verify_next`
+ to indicate that the object's internal state has been modified since creation.
+
+ (Note: All internal state attribute can be initialized via constructor options,
+ but this is mainly an internal / testing detail).
+ """
+ #=============================================================================
+ # class attrs
+ #=============================================================================
+
+ #: otpauth type this class implements
+ type = "hotp"
+
+ #=============================================================================
+ # instance attrs
+ #=============================================================================
+
+ #: initial counter value (if configured from server)
+ start = 0
+
+ #: counter of next token to generate.
+ counter = 0
+
+ #=============================================================================
+ # init
+ #=============================================================================
+ def __init__(self, key=None, format="base32",
+ # keyword only ...
+ start=0, counter=0,
+ **kwds):
+ # call BaseOTP to handle common options
+ super(HOTP, self).__init__(key, format, **kwds)
+
+ # validate counter
+ self._check_serial(counter, "counter")
+ self.counter = counter
+
+ # validate start
+ # NOTE: when loading from URI, 'start' is set to match counter,
+ # as we can trust server won't take any older values.
+ # other than that case, 'start' generally isn't used.
+ self._check_serial(start, "start")
+ if start > self.counter:
+ raise ValueError("start must be <= counter (%d)" % self.counter)
+ self.start = start
+
+ #=============================================================================
+ # token management
+ #=============================================================================
+ def _normalize_counter(self, counter):
+ """
+ helper to normalize counter representation
+ """
+ if not isinstance(counter, int_types):
+ raise exc.ExpectedTypeError(counter, "int", "counter")
+ if counter < self.start:
+ raise ValueError("counter must be >= start value (%d)" % self.start)
+ return counter
+
+ def generate(self, counter):
+ """
+ Low-level method to generate HOTP token for specified counter value.
+
+ :arg int counter:
+ counter value to use.
+
+ :returns:
+ (unicode) string containing decimal-formatted token
+
+ Usage example::
+
+ >>> h = HOTP('s3jdvb7qd2r7jpxx')
+ >>> h.generate(1000)
+ '763224'
+ >>> h.generate(1001)
+ '771031'
+
+ .. seealso::
+ This is a lowlevel method, which doesn't read or modify any state-dependant values
+ (such as the current :attr:`counter` value).
+ For a version which does, see :meth:`generate_next`.
+ """
+ counter = self._normalize_counter(counter)
+ return self._generate(counter)
+
+ def generate_next(self):
+ """
+ High-level method to generate a new HOTP token using next counter value.
+
+ Unlike :meth:`generate`, this method uses the current :attr:`counter` value,
+ and increments that counter before it returns.
+
+ :returns:
+ (unicode) string containing decimal-formatted token
+
+ Usage example::
+
+ >>> h = HOTP('s3jdvb7qd2r7jpxx', counter=1000)
+ >>> h.counter
+ 1000
+ >>> h.generate_next()
+ '897212'
+ >>> h.counter
+ 1001
+ """
+ counter = self.counter
+ token = self.generate(counter)
+ self.counter = counter + 1 # NOTE: not incrementing counter until generate succeeds
+ self.dirty = True
+ return token
+
+ def verify(self, token, counter, window=1):
+ """
+ Low-level method to validate HOTP token against specified counter.
+
+ :arg token:
+ token to validate.
+ may be integer or string (whitespace and hyphens are ignored).
+
+ :param int counter:
+ next counter value client was expected to use.
+
+ :param window:
+ How many additional steps past ``counter`` to search when looking for a match
+ Defaults to 1.
+
+ .. rst-class:: inline-title
+ .. note::
+ This is a forward-looking window only, as searching backwards
+ would allow token-reuse, defeating the whole purpose of HOTP.
+
+ :returns:
+
+ ``(ok, counter)`` tuple (actually an :class:`HotpMatch` instance):
+
+ * ``ok`` -- boolean indicating if token validated
+ * ``counter`` -- if token validated, this is the new counter value (matched token value + 1);
+ or the previous counter value if token didn't validate.
+
+ Usage example::
+
+ >>> h = HOTP('s3jdvb7qd2r7jpxx')
+ >>> h.verify('897212', 1000) # token matches counter
+ (True, 1000)
+ >>> h.verify('897212', 999) # token w/in window=1
+ (True, 1000)
+ >>> h.verify('897212', 998) # token outside window
+ (False, 998)
+
+ .. seealso::
+ This is a lowlevel method, which doesn't read or modify any state-dependant values
+ (such as the next :attr:`counter` value).
+ For a version which does, see :meth:`verify_next`.
+ """
+ counter = self._normalize_counter(counter)
+ self._check_serial(window, "window")
+ valid, match = self._find_match(token, counter, counter + window + 1)
+ if valid:
+ return HotpMatch(True, match + 1, match - counter)
+ else:
+ return HotpMatch(False, counter, 0)
+
+ def verify_next(self, token, window=1):
+ """
+ High-level method to validate HOTP token against current counter value.
+
+ Unlike :meth:`verify`, this method uses the current :attr:`counter` value,
+ and updates that counter after a successful verification.
+
+ :arg token:
+ token to validate.
+ may be integer or string (whitespace and hyphens are ignored).
+
+ :param window:
+ How many additional steps past ``counter`` to search when looking for a match
+ Defaults to 1.
+
+ .. rst-class:: inline-title
+ .. note::
+ This is a forward-looking window only, as using a backwards window
+ would allow token-reuse, defeating the whole purpose of HOTP.
+
+ :returns:
+ boolean indicating if token validated
+
+ Usage example::
+
+ >>> h = HOTP('s3jdvb7qd2r7jpxx', counter=998)
+ >>> h.verify_next('897212') # token outside window
+ False
+ >>> h.counter # counter not incremented
+ 998
+ >>> h.verify_next('484807') # token matches counter 999, w/in window=1
+ True
+ >>> h.counter # counter has been incremented, now expecting counter=1000 next
+ 1000
+ """
+ counter = self.counter
+ result = self.verify(token, counter, window=window)
+ if result.valid:
+ self.counter = result.counter
+ self.dirty = True
+ # XXX: return result instead? would only provide .skipped as extra data.
+ return result.valid
+
+ # TODO: resync(self, tokens, counter, window=100)
+ # helper to re-synchronize using series of sequential tokens,
+ # all of which must validate; per RFC recommendation.
+
+ #=============================================================================
+ # uri parsing
+ #=============================================================================
+ @classmethod
+ def _adapt_uri_params(cls, counter=None, **kwds):
+ """
+ parse HOTP specific params, and let _BaseOTP handle rest.
+ """
+ kwds = super(HOTP, cls)._adapt_uri_params(**kwds)
+ if counter is None:
+ raise cls._uri_error("missing 'counter' parameter")
+ # NOTE: when creating from a URI, we set the 'start' value as well,
+ # as sanity check on client-side, since we *know* minimum value
+ # server will accept.
+ kwds['counter'] = kwds['start'] = cls._uri_parse_int(counter, "counter")
+ return kwds
+
+ #=============================================================================
+ # uri rendering
+ #=============================================================================
+ def _to_uri_params(self):
+ """
+ add HOTP specific params, and let _BaseOTP handle rest.
+ """
+ args = super(HOTP, self)._to_uri_params()
+ args.append(("counter", str(self.counter)))
+ return args
+
+ #=============================================================================
+ # json rendering
+ #=============================================================================
+ def _to_json(self):
+ kwds = super(HOTP, self)._to_json()
+ if self.start:
+ kwds['start'] = self.start
+ if self.counter:
+ kwds['counter'] = self.counter
+ return kwds
+
+ #=============================================================================
+ # eoc
+ #=============================================================================
+
+# register subclass with from_uri() helper
+BaseOTP._type_map[HOTP.type] = HOTP
+
+#=============================================================================
+# TOTP helper
+#=============================================================================
+class TotpToken(_SequenceMixin):
+ """
+ Object returned by :meth:`TOTP.generate` and :meth:`TOTP.generate_next`.
+ It can be treated as a sequence of ``(token, expire_time)``,
+ or accessed via the following attributes:
+
+ .. autoattribute:: token
+ .. autoattribute:: expire_time
+ .. autoattribute:: counter
+
+ ..
+ undocumented attributes::
+
+ .. autoattribute:: remaining
+ .. autoattribute:: valid
+ """
+ #: OTP object that generated this token
+ _otp = None
+
+ #: Token as decimal-encoded ascii string.
+ token = None
+
+ #: HOTP counter value used to generate token (derived from time)
+ counter = None
+
+ def __init__(self, otp, token, counter):
+ self._otp = otp
+ self.token = token
+ self.counter = counter
+
+ def _as_tuple(self):
+ return (self.token, self.expire_time)
+
+ # @memoized_property
+ # def start_time(self):
+ # """Timestamp marking beginning of period when token is valid"""
+ # return self.counter * self._otp.period
+
+ @memoized_property
+ def expire_time(self):
+ """Timestamp marking end of period when token is valid"""
+ return (self.counter + 1) * self._otp.period
+
+ @property
+ def remaining(self):
+ """number of (float) seconds before token expires"""
+ return max(0, self.expire_time - self._otp.now())
+
+ @property
+ def valid(self):
+ """whether token is still valid"""
+ return bool(self.remaining)
+
+class TotpMatch(_SequenceMixin):
+ """
+ Object returned by :meth:`TOTP.verify`.
+ It can be treated as a sequence of ``(valid, offset)``,
+ or accessed via the following attributes:
+
+ .. autoattribute:: valid
+ .. autoattribute:: offset
+
+ ..
+ undocumented attributes:
+
+ .. autoattribute:: time
+ .. autoattribute:: counter
+ .. autoattribute:: counter_offset
+ .. autoattribute:: _previous_offset
+ .. autoattribute:: _period
+ """
+ #: bool flag indicating whether token matched
+ #: (also reflected as object's overall boolean value)
+ valid = False
+
+ #: TOTP counter value which token matched against;
+ #: or ``0`` if there was no match.
+ counter = 0
+
+ #: Timestamp when verification was performed
+ time = 0
+
+ #: Previous offset value provided when verify() was called.
+ _previous_offset = 0
+
+ #: TOTP period (needed internally to calculate min_offset, etc).
+ _period = 30
+
+ def __init__(self, valid, counter, time, previous_offset, period):
+ """
+ .. warning::
+ the constructor signature is an internal detail, and is subject to change.
+ """
+ self.valid = valid
+ self.time = time
+ self.counter = counter
+ self._previous_offset = previous_offset
+ self._period = period
+
+ @memoized_property
+ def counter_offset(self):
+ """
+ Number of integer counter steps that match was off from current time's counter step.
+ """
+ if not self.valid:
+ return 0
+ return self.counter - self.time // self._period
+
+ @memoized_property
+ def offset(self):
+ """
+ Suggested offset value for next time a token is verified from this client.
+ If no match, reports previously provided offset value.
+ """
+ if not self.valid:
+ return self._previous_offset
+ return suggest_offset(history=[(self.time, self.counter_offset)],
+ period=self._period, default=self._previous_offset)
+
+ def _as_tuple(self):
+ return (self.valid, self.offset)
+
+ def __nonzero__(self):
+ return self.valid
+
+ __bool__ = __nonzero__ # py2 compat
+
+class TOTP(BaseOTP):
+ """Helper for generating and verifying TOTP codes.
+
+ Given a secret key and set of configuration options, this object
+ offers methods for token generation, token validation, and serialization.
+ It can also be used to track important persistent TOTP state,
+ such as clock drift, and last counter used.
+
+ Constructor Options
+ ===================
+ In addition to the :ref:`BaseOTP Constructor Options <baseotp-constructor-options>`,
+ this class accepts the following extra parameters:
+
+ :param int period:
+ The time-step period to use, in integer seconds. Defaults to ``30``.
+
+ :param now:
+ Optional callable that should return current time for generator to use.
+ Default to :func:`time.time`. This optional is generally not needed,
+ and is mainly present for examples & unit-testing.
+
+ .. warning::
+
+ Overriding the default values for ``digits``, ``period``, or ``alg`` may
+ cause problems with some OTP client programs. For instance, Google Authenticator
+ claims it's defaults are hard-coded.
+
+ Client-Side Token Generation
+ ============================
+ .. automethod:: generate
+ .. automethod:: generate_next
+
+ Server-Side Token Verification
+ ==============================
+ .. automethod:: verify
+ .. automethod:: verify_next
+
+ .. todo::
+
+ Offer a resynchronization primitive which allows user to provide a large number of
+ sequential tokens taken from a pre-determined time range (e.g.
+ google's "emergency recovery code" style); or at current time, but with a much larger
+ window (as referenced in the RFC).
+
+ Provisioning & Serialization
+ ============================
+ The shared provisioning & serialization methods for the :class:`!TOTP` and :class:`!HOTP` classes
+ are documented under:
+
+ * :ref:`BaseOTP Client Provisioning <baseotp-client-provisioning>`
+ * :ref:`BaseOTP Serialization <baseotp-serialization>`
+
+ ..
+ Undocumented Helper Methods
+ ===========================
+
+ .. automethod:: normalize_time
+
+ Configuration Attributes
+ ========================
+ In addition to the :ref:`BaseOTP Configuration Attributes <baseotp-configuration-attributes>`,
+ this class also offers the following extra attrs (which correspond to the extra constructor options):
+
+ .. autoattribute:: period
+
+ Internal State Attributes
+ =========================
+ The following attributes are used to track the internal state of this generator,
+ and will be included in the output of :meth:`to_string`:
+
+ .. autoattribute:: last_counter
+
+ .. autoattribute:: _history
+
+ .. attribute:: dirty
+
+ boolean flag set by :meth:`generate_next` and :meth:`verify_next`
+ to indicate that the object's internal state has been modified since creation.
+
+ (Note: All internal state attribute can be initialized via constructor options,
+ but this is mainly an internal / testing detail).
+ """
+ #=============================================================================
+ # class attrs
+ #=============================================================================
+
+ #: otpauth type this class implements
+ type = "totp"
+
+ #: max history buffer size
+ # NOTE: picked based on average size that suggest_offset() algorithm
+ # needs to settle down on predicted value, using `history1` from unittest as reference.
+ MAX_HISTORY_SIZE = 8
+
+ #=============================================================================
+ # instance attrs
+ #=============================================================================
+
+ #: function to get system time in seconds, as needed by :meth:`generate` and :meth:`verify`.
+ #: defaults to :func:`time.time`, but can be overridden on a per-instance basis.
+ now = _time.time
+
+ #: number of seconds per counter step.
+ #: *(TOTP uses an internal time-derived counter which
+ #: increments by 1 every* :attr:`!period` *seconds)*.
+ period = 30
+
+ #---------------------------------------------------------------------------
+ # state attrs
+ #---------------------------------------------------------------------------
+
+ #: counter value of last token generated by :meth:`generate_next` *(client-side)*,
+ #: or validated by :meth:`verify_next` *(server-side)*.
+ last_counter = 0
+
+ #: *(server-side only)* history of previous verifications performed by :meth:`verify_next`,
+ #: and is used to estimate the **delay** parameter on a per-client basis.
+ #:
+ #: this is an internal attribute whose structure is subject to change,
+ #: but currently is a list of 1 or more ``(timestamp, counter_offset)`` entries.
+ #: it's maximum size is controlled by the class attribute ``TOTP.MAX_HISTORY_SIZE``.
+ _history = None
+
+ #=============================================================================
+ # init
+ #=============================================================================
+ def __init__(self, key=None, format="base32",
+ # keyword only...
+ period=None,
+ last_counter=0, _history=None,
+ now=None, # NOTE: mainly used for unittesting
+ **kwds):
+ # call BaseOTP to handle common options
+ super(TOTP, self).__init__(key, format, **kwds)
+
+ # use custom timer --
+ # intended for examples & unittests, not real-world use.
+ if now:
+ assert isinstance(now(), num_types) and now() >= 0, \
+ "now() function must return non-negative int/float"
+ self.now = now
+
+ # init period
+ if period is not None:
+ self._check_serial(period, "period", minval=1)
+ self.period = period
+
+ # init last counter value
+ self._check_serial(last_counter, "last_counter")
+ self.last_counter = last_counter
+
+ # init history
+ if _history:
+ # TODO: run sanity check on structure of history object
+ self._history = _history
+
+ #=============================================================================
+ # token management
+ #=============================================================================
+
+ #-------------------------------------------------------------------------
+ # internal helpers
+ #-------------------------------------------------------------------------
+ def normalize_time(self, time):
+ """
+ Normalize time value to unix epoch seconds.
+
+ :arg time:
+ Can be ``None``, :class:`!datetime`,
+ or unix epoch timestamp as :class:`!float` or :class:`!int`.
+ If ``None``, uses current system time.
+ Naive datetimes are treated as UTC.
+
+ :returns:
+ unix epoch timestamp as :class:`int`.
+ """
+ if isinstance(time, int_types):
+ return time
+ elif isinstance(time, float):
+ return int(time)
+ elif time is None:
+ return int(self.now())
+ elif hasattr(time, "utctimetuple"):
+ # coerce datetime to UTC timestamp
+ # NOTE: utctimetuple() assumes naive datetimes are in UTC
+ # NOTE: we explicitly *don't* want microseconds.
+ return calendar.timegm(time.utctimetuple())
+ else:
+ raise exc.ExpectedTypeError(time, "int, float, or datetime", "time")
+
+ def _time_to_counter(self, time):
+ """
+ convert timestamp to HOTP counter using :attr:`period`.
+ input is passed through :meth:`normalize_time`.
+ """
+ time = self.normalize_time(time)
+ if time < 0:
+ raise ValueError("time must be >= 0")
+ return time // self.period
+
+ #-------------------------------------------------------------------------
+ # token generation
+ #-------------------------------------------------------------------------
+ def generate(self, time=None):
+ """
+ Low-level method to generate token for specified time.
+
+ :arg time:
+ Can be ``None``, :class:`!datetime`,
+ or unix epoch timestamp as :class:`!float` or :class:`!int`.
+ If ``None`` (the default), uses current system time.
+ Naive datetimes are treated as UTC.
+
+ :returns:
+
+ sequence of ``(token, expire_time)`` (actually a :class:`TotpToken` instance):
+
+ * ``token`` -- decimal-formatted token as a (unicode) string
+ * ``expire_time`` -- unix epoch time when token will expire
+
+ Usage example::
+
+ >>> otp = TOTP('s3jdvb7qd2r7jpxx')
+ >>> otp.generate(1419622739)
+ ('897212', 1419622740)
+
+ >>> # when you just need the token...
+ >>> otp.generate(1419622739).token
+ '897212'
+
+ .. seealso::
+ This is a lowlevel method, which doesn't read or modify any state-dependant values
+ (such as the :attr:`last_counter` value).
+ For a version which does, see :meth:`generate_next`.
+ """
+ counter = self._time_to_counter(time)
+ token = self._generate(counter)
+ return TotpToken(self, token, counter)
+
+ def generate_next(self, reuse=False):
+ """
+ High-level method to generate TOTP token for current time.
+ Unlike :meth:`generate`, this method takes into account the :attr:`last_counter` value,
+ and updates that attribute to match the returned token.
+
+ :param reuse:
+ Controls whether a token can be issued twice within the same time :attr:`period`.
+
+ By default (``False``), calling this method twice within the same time :attr:`period`
+ will result in a :exc:`~passlib.exc.TokenReuseError`, since once a token has gone across the wire,
+ it should be considered insecure.
+
+ Setting this to ``True`` will allow multiple uses of the token within the same time period.
+
+ :returns:
+
+ sequence of ``(token, expire_time)`` (actually a :class:`TotpToken` instance):
+
+ * ``token`` -- decimal-formatted token as a (unicode) string
+ * ``expire_time`` -- unix epoch time when token will expire
+
+ :raises ~passlib.exc.TokenReuseError:
+
+ if an attempt is made to generate a token within the same time :attr:`period`
+ (suppressed by ``reuse=True``).
+
+ Usage example::
+
+ >>> # IMPORTANT: THE 'now' PARAMETER SHOULD NOT BE USED IN PRODUCTION.
+ >>> # It's only used here to fix the totp generator's clock, so that
+ >>> # this example can be reproduced regardless of the actual system time.
+ >>> totp = TOTP('s3jdvb7qd2r7jpxx', now=lambda : 1419622739)
+ >>> totp.generate_next() # generate new token
+ ('897212', 1419622740)
+
+ >>> # or use attr access when you just need the token ...
+ >>> totp.generate_next().token
+ '897212'
+ """
+ time = self.normalize_time(None)
+ result = self.generate(time)
+
+ if result.counter < self.last_counter:
+ # NOTE: this usually means system time has jumped back since last call.
+ # this will occasionally happen, so not throwing an error,
+ # but definitely worth issuing a warning.
+ warn("TOTP.generate_next(): current time (%r) earlier than last-used time (%r); "
+ "did system clock change?" % (int(time), self.last_counter * self.period),
+ exc.PasslibSecurityWarning, stacklevel=1)
+
+ elif result.counter == self.last_counter and not reuse:
+ raise exc.TokenReuseError("Token already generated in this time period, "
+ "please wait %d seconds for another." % result.remaining,
+ expire_time=result.expire_time)
+
+ self.last_counter = result.counter
+ self.dirty = True
+ return result
+
+ #-------------------------------------------------------------------------
+ # token verification
+ #-------------------------------------------------------------------------
+
+ def verify(self, token, time=None, window=30, offset=0, min_start=0):
+ """
+ Low-level method to validate TOTP token against specified timestamp.
+ Searches within a window before & after the provided time,
+ in order to account for transmission offset and drift in the client's clock.
+
+ :arg token:
+ Token to validate.
+ may be integer or string (whitespace and hyphens are ignored).
+
+ :param time:
+ Unix epoch timestamp, can be any of :class:`!float`, :class:`!int`, or :class:`!datetime`.
+ if ``None`` (the default), uses current system time.
+ *this should correspond to the time the token was received from the client*.
+
+ :param int window:
+ How far backward and forward in time to search for a match.
+ Measured in seconds. Defaults to ``30``. Typically only useful if set
+ to multiples of :attr:`period`.
+
+ :param int offset:
+ Offset timestamp by specified value, to account for transmission offset and / or client clock skew.
+ Measured in seconds. Defaults to ``0``.
+
+ Negative offset (the common case) indicates transmission delay,
+ or that the client clock is running behind the server.
+ Positive offset indicates the client clock is running ahead of the server
+ (and by enough that it cancels out the transmission delay).
+
+ .. note::
+
+ You should ensure the server clock uses a reliable time source such as NTP,
+ so that only the client clock needs to be accounted for.
+
+ :returns:
+ sequence of ``(valid, offset)`` (actually a :class:`TotpMatch` instance):
+
+ * ``valid`` -- boolean flag indicating whether token matched
+ * ``offset`` -- suggested offset value for next time token is verified from this client.
+
+ :raises ValueError:
+ if the provided token is not correctly formatted (e.g. wrong number of digits),
+ or if one of the parameters has an invalid value.
+
+ Usage example::
+
+ >>> totp = TOTP('s3jdvb7qd2r7jpxx')
+ >>> totp.verify('897212', 1419622729) # valid token for this time period
+ (True, 19)
+ >>> totp.verify('000492', 1419622729) # token from counter step 30 sec ago (within allowed window)
+ (True, 49)
+ >>> totp.verify('760389', 1419622729) # invalid token -- token from 60 sec ago (outside of window)
+ (False, 0)
+
+ .. seealso::
+ This is a low-level method, which doesn't read or modify any state-dependant values
+ (such as the :attr:`last_counter` value, or the previously recorded :attr:`drift`).
+ For a version which does, see :meth:`verify_next`.
+ """
+ time = self.normalize_time(time)
+ self._check_serial(window, "window")
+
+ # NOTE: 'min_start' is internal parameter used by verify_next() to
+ # skip searching any counter values before last confirmed verification.
+ client_time = time + offset
+ start = max(min_start, self._time_to_counter(client_time - window))
+ end = self._time_to_counter(client_time + window) + 1
+
+ valid, counter = self._find_match(token, start, end)
+ return TotpMatch(valid, counter, time, offset, self.period)
+
+ def verify_next(self, token, reuse=False, window=30, offset=None):
+ """
+ High-level method to validate TOTP token against current system time.
+ Unlike :meth:`verify`, this method takes into account the :attr:`last_counter` value,
+ and updates that attribute if a match is found.
+
+ Additionally, this method also stores an internal :attr;`_history` of previous successful
+ verifications, which it uses to autocalculate the offset parameter before each call
+ (in order to account for client clock drift).
+
+ :arg token:
+ token to validate.
+ may be integer or string (whitespace and hyphens are ignored).
+
+ :param bool reuse:
+ Controls whether a token can be issued twice within the same time :attr:`period`.
+
+ By default (``False``), attempting to verify the same token twice within the same time :attr:`period`
+ will result in a :exc:`~passlib.exc.TokenReuseError`, since once a token has gone across the wire,
+ it should be considered insecure.
+
+ Setting this to ``True`` will silently allow multiple uses of the token within the same time period.
+
+ :param int window:
+ How far backward and forward in time to search for a match.
+ Measured in seconds. Defaults to ``30``. Typically only useful if set
+ to multiples of :attr:`period`.
+
+ :returns:
+ Returns ``True`` if the token validated, ``False`` if not.
+
+ May set the :attr:`dirty` attribute if the internal state was updated,
+ and needs to be re-persisted by the application (see :meth:`to_json`).
+
+ :raises ValueError:
+ If the provided token is not correctly formed (e.g. wrong number of digits),
+ or if one of the parameters has an invalid value.
+
+ :raises ~passlib.exc.TokenReuseError:
+
+ If an attempt is made to verify the current time period's token
+ (suppressed by ``reuse=True``).
+
+ Usage example::
+
+ >>> # IMPORTANT: THE 'now' PARAMETER SHOULD NOT BE USED IN PRODUCTION.
+ >>> # It's only used here to fix the totp generator's clock, so that
+ >>> # this example can be reproduced regardless of the actual system time.
+ >>> totp = TOTP('s3jdvb7qd2r7jpxx', now = lambda: 1419622739)
+ >>> # wrong token
+ >>> totp.verify_next('123456')
+ False
+ >>> # token from 30 sec ago (w/ window, will be accepted)
+ >>> totp.verify_next('000492')
+ True
+ >>> # token from current period
+ >>> totp.verify_next('897212')
+ True
+ >>> # token from 30 sec ago will now be rejected
+ >>> totp.verify_next('000492')
+ False
+ """
+ time = self.normalize_time(None)
+ if offset is None:
+ offset = self._next_offset(time)
+ # NOTE: setting min_start so verify() doesn't even bother checking
+ # points before the last verified counter, no matter what offset or window is set to.
+ result = self.verify(token, time, window=window, offset=offset, min_start=self.last_counter)
+ assert result.time == time, "sanity check failed: verify().time didn't match input time"
+ if not result.valid:
+ return False
+
+ if result.counter > self.last_counter:
+ # accept new token, update internal state
+ self.last_counter = result.counter
+ self._add_offset(result.time, result.counter_offset)
+ self.dirty = True
+ return True
+
+ assert result.counter == self.last_counter, "sanity check failed: 'min_start' not honored"
+
+ if reuse:
+ # allow reuse of current token
+ return True
+
+ else:
+ raise exc.TokenReuseError("Token has already been used, please wait for another.",
+ expire_time=(self.last_counter + 1) * self.period)
+
+ def _next_offset(self, time):
+ """
+ internal helper for :meth:`verify_next` --
+ return suggested offset for specified time, based on history.
+ """
+ return suggest_offset(self._history, self.period, time)
+
+ def _add_offset(self, time, counter_offset):
+ """
+ internal helper for :meth:`verify_next` --
+ appends an entry to the verification history.
+ """
+ history = self._history
+ if history:
+ # add entry to history
+ history.append((time, counter_offset))
+
+ # remove old entries
+ while len(history) > self.MAX_HISTORY_SIZE:
+ history.pop(0)
+
+ elif self.MAX_HISTORY_SIZE > 0:
+ # initialize history (if it hasn't been disabled)
+ self._history = [(time, counter_offset)]
+
+ #-------------------------------------------------------------------------
+ # TODO: resync(self, tokens, time=None, min_tokens=10, window=100)
+ # helper to re-synchronize using series of sequential tokens,
+ # all of which must validate; per RFC recommendation.
+ # NOTE: need to make sure this function is constant time
+ # (i.e. scans ALL tokens, and doesn't short-circuit after first mismatch)
+ #-------------------------------------------------------------------------
+
+ #=============================================================================
+ # uri parsing
+ #=============================================================================
+ @classmethod
+ def _adapt_uri_params(cls, period=None, **kwds):
+ """
+ parse TOTP specific params, and let _BaseOTP handle rest.
+ """
+ kwds = super(TOTP, cls)._adapt_uri_params(**kwds)
+ if period:
+ kwds['period'] = cls._uri_parse_int(period, "period")
+ return kwds
+
+ #=============================================================================
+ # uri rendering
+ #=============================================================================
+ def _to_uri_params(self):
+ """
+ add TOTP specific arguments to URI, and let _BaseOTP handle rest.
+ """
+ args = super(TOTP, self)._to_uri_params()
+ if self.period != 30:
+ args.append(("period", str(self.period)))
+ return args
+
+ #=============================================================================
+ # json rendering
+ #=============================================================================
+ def _to_json(self):
+ kwds = super(TOTP, self)._to_json()
+ if self.period != 30:
+ kwds['period'] = self.period
+ if self.last_counter:
+ kwds['last_counter'] = self.last_counter
+ if self._history:
+ kwds['_history'] = self._history
+ return kwds
+
+ #=============================================================================
+ # eoc
+ #=============================================================================
+
+# register subclass with from_uri() helper
+BaseOTP._type_map[TOTP.type] = TOTP
+
+#=============================================================================
+# public frontends
+#=============================================================================
+def from_uri(uri):
+ """
+ create an OTP instance from a URI, such as returned by :meth:`TOTP.to_uri`.
+
+ :raises ValueError:
+ if the uri cannot be parsed or contains errors.
+
+ :returns:
+ :class:`TOTP` or :class:`HOTP` instance, as appropriate.
+ """
+ return BaseOTP.from_uri(uri)
+
+def from_string(json, password=None):
+ """
+ load an OTP instance from serialized json, such as returned by :meth:`TOTP.to_json`.
+
+ :raises ValueError:
+ if the json cannot be parsed or contains errors.
+
+ :returns:
+ :class:`TOTP` or :class:`HOTP` instance, as appropriate.
+ """
+ return BaseOTP.from_string(json, password=password)
+
+#=============================================================================
+# eof
+#=============================================================================
diff --git a/passlib/utils/__init__.py b/passlib/utils/__init__.py
index b4c3f90..4bc45c0 100644
--- a/passlib/utils/__init__.py
+++ b/passlib/utils/__init__.py
@@ -2,7 +2,7 @@
#=============================================================================
# imports
#=============================================================================
-from passlib.utils.compat import PYPY, JYTHON
+from passlib.utils.compat import JYTHON
# core
from base64 import b64encode, b64decode
from codecs import lookup as _lookup_codec
@@ -12,6 +12,7 @@ import math
import os
import sys
import random
+import re
if JYTHON: # pragma: no cover -- runtime detection
# Jython 2.5.2 lacks stringprep module -
# see http://bugs.jython.org/issue1758320
@@ -29,13 +30,12 @@ from warnings import warn
# site
# pkg
from passlib.exc import ExpectedStringError
-from passlib.utils.compat import add_doc, b, bytes, join_bytes, join_byte_values, \
- join_byte_elems, exc_err, irange, imap, PY3, u, \
- join_unicode, unicode, byte_elem_value, PY_MIN_32, next_method_attr
+from passlib.utils.compat import add_doc, join_bytes, join_byte_values, \
+ join_byte_elems, irange, imap, PY3, u, \
+ join_unicode, unicode, byte_elem_value, nextgetter
# local
__all__ = [
# constants
- 'PYPY',
'JYTHON',
'sys_bits',
'unix_crypt_schemes',
@@ -109,7 +109,7 @@ rounds_cost_values = [ "linear", "log2" ]
from passlib.exc import MissingBackendError
# internal helpers
-_BEMPTY = b('')
+_BEMPTY = b''
_UEMPTY = u("")
_USPACE = u(" ")
@@ -481,7 +481,8 @@ def render_bytes(source, *args):
else arg for arg in args)
return result.encode("latin-1")
-if PY_MIN_32:
+if PY3:
+ # new in py32
def bytes_to_int(value):
return int.from_bytes(value, 'big')
def int_to_bytes(value, count):
@@ -491,13 +492,8 @@ else:
from binascii import hexlify, unhexlify
def bytes_to_int(value):
return int(hexlify(value),16)
- if PY3:
- # grr, why did py3 have to break % for bytes?
- def int_to_bytes(value, count):
- return unhexlify((('%%0%dx' % (count<<1)) % value).encode("ascii"))
- else:
- def int_to_bytes(value, count):
- return unhexlify(('%%0%dx' % (count<<1)) % value)
+ def int_to_bytes(value, count):
+ return unhexlify(('%%0%dx' % (count<<1)) % value)
add_doc(bytes_to_int, "decode byte string as single big-endian integer")
add_doc(int_to_bytes, "encode integer as single big-endian byte string")
@@ -515,7 +511,7 @@ def repeat_string(source, size):
else:
return source[:size]
-_BNULL = b("\x00")
+_BNULL = b"\x00"
_UNULL = u("\x00")
def right_pad_string(source, size, pad=None):
@@ -531,7 +527,7 @@ def right_pad_string(source, size, pad=None):
#=============================================================================
# encoding helpers
#=============================================================================
-_ASCII_TEST_BYTES = b("\x00\n aA:#!\x7f")
+_ASCII_TEST_BYTES = b"\x00\n aA:#!\x7f"
_ASCII_TEST_UNICODE = _ASCII_TEST_BYTES.decode("ascii")
def is_ascii_codec(codec):
@@ -546,7 +542,7 @@ def is_same_codec(left, right):
return False
return _lookup_codec(left).name == _lookup_codec(right).name
-_B80 = b('\x80')[0]
+_B80 = b'\x80'[0]
_U80 = u('\x80')
def is_ascii_safe(source):
"""Check if string (bytes or unicode) contains only 7-bit ascii"""
@@ -799,9 +795,9 @@ class Base64Engine(object):
raise TypeError("source must be bytes, not %s" % (type(source),))
chunks, tail = divmod(len(source), 3)
if PY3:
- next_value = iter(source).__next__
+ next_value = nextgetter(iter(source))
else:
- next_value = (ord(elem) for elem in source).next
+ next_value = nextgetter(ord(elem) for elem in source)
gen = self._encode_bytes(next_value, chunks, tail)
out = join_byte_elems(imap(self._encode64, gen))
##if tail:
@@ -908,11 +904,10 @@ class Base64Engine(object):
if tail == 1:
# only 6 bits left, can't encode a whole byte!
raise ValueError("input string length cannot be == 1 mod 4")
- next_value = getattr(imap(self._decode64, source), next_method_attr)
+ next_value = nextgetter(imap(self._decode64, source))
try:
return join_byte_values(self._decode_bytes(next_value, chunks, tail))
- except KeyError:
- err = exc_err()
+ except KeyError as err:
raise ValueError("invalid character: %r" % (err.args[0],))
def _decode_bytes_little(self, next_value, chunks, tail):
@@ -1289,10 +1284,10 @@ bcrypt64 = LazyBase64Engine(BCRYPT_CHARS, big=True)
#=============================================================================
# adapted-base64 encoding
#=============================================================================
-_A64_ALTCHARS = b("./")
-_A64_STRIP = b("=\n")
-_A64_PAD1 = b("=")
-_A64_PAD2 = b("==")
+_A64_ALTCHARS = b"./"
+_A64_STRIP = b"=\n"
+_A64_PAD1 = b"="
+_A64_PAD2 = b"=="
def ab64_encode(data):
"""encode using variant of base64
@@ -1430,6 +1425,13 @@ else:
# On most other platforms the best timer is time.time()
from time import time as tick
+def parse_version(source):
+ """helper to parse version string"""
+ m = re.search(r"(\d+(?:\.\d+)+)", source)
+ if m:
+ return tuple(int(elem) for elem in m.group(1).split("."))
+ return None
+
#=============================================================================
# randomness
#=============================================================================
@@ -1540,6 +1542,8 @@ def getrandstr(rng, charset, count):
_52charset = '2346789ABCDEFGHJKMNPQRTUVWXYZabcdefghjkmnpqrstuvwxyz'
+@deprecated_function(deprecated="1.7", removed="2.0",
+ replacement="passlib.pwd.generate()")
def generate_password(size=10, charset=_52charset):
"""generate random password using given length & charset
diff --git a/passlib/utils/_blowfish/__init__.py b/passlib/utils/_blowfish/__init__.py
index 3281be9..87a37cf 100644
--- a/passlib/utils/_blowfish/__init__.py
+++ b/passlib/utils/_blowfish/__init__.py
@@ -55,7 +55,7 @@ from itertools import chain
import struct
# pkg
from passlib.utils import bcrypt64, getrandbytes, rng
-from passlib.utils.compat import b, bytes, BytesIO, unicode, u, native_string_types
+from passlib.utils.compat import BytesIO, unicode, u, native_string_types
from passlib.utils._blowfish.unrolled import BlowfishEngine
# local
__all__ = [
@@ -82,7 +82,7 @@ digest_struct = struct.Struct(">6I")
# interface designed only for use by passlib.handlers.bcrypt:BCrypt
# probably not suitable for other purposes
#=============================================================================
-BNULL = b('\x00')
+BNULL = b'\x00'
def raw_bcrypt(password, ident, salt, log_rounds):
"""perform central password hashing step in bcrypt scheme.
@@ -90,7 +90,7 @@ def raw_bcrypt(password, ident, salt, log_rounds):
:param password: the password to hash
:param ident: identifier w/ minor version (e.g. 2, 2a)
:param salt: the binary salt to use (encoded in bcrypt-base64)
- :param rounds: the log2 of the number of rounds (as int)
+ :param log_rounds: the log2 of the number of rounds (as int)
:returns: bcrypt-base64 encoded checksum
"""
#===================================================================
diff --git a/passlib/utils/_blowfish/base.py b/passlib/utils/_blowfish/base.py
index 5621e4c..b0a761e 100644
--- a/passlib/utils/_blowfish/base.py
+++ b/passlib/utils/_blowfish/base.py
@@ -5,7 +5,6 @@
# core
import struct
# pkg
-from passlib.utils.compat import bytes
from passlib.utils import repeat_string
# local
__all__ = [
diff --git a/passlib/utils/compat.py b/passlib/utils/compat/__init__.py
index 4cf9b81..51c5b72 100644
--- a/passlib/utils/compat.py
+++ b/passlib/utils/compat/__init__.py
@@ -9,22 +9,20 @@
import sys
PY2 = sys.version_info < (3,0)
PY3 = sys.version_info >= (3,0)
-PY_MAX_25 = sys.version_info < (2,6) # py 2.5 or earlier
-PY27 = sys.version_info[:2] == (2,7) # supports last 2.x release
-PY_MIN_32 = sys.version_info >= (3,2) # py 3.2 or later
+
+# make sure it's not an unsupported version, even if we somehow got this far
+if sys.version_info < (2,6) or (3,0) <= sys.version_info < (3,2):
+ raise RuntimeError("Passlib requires Python 2.6, 2.7, or >= 3.2 (as of passlib 1.7)")
+
+PY26 = sys.version_info < (2,7)
#------------------------------------------------------------------------
# python implementation
#------------------------------------------------------------------------
-PYPY = hasattr(sys, "pypy_version_info")
JYTHON = sys.platform.startswith('java')
-#------------------------------------------------------------------------
-# capabilities
-#------------------------------------------------------------------------
-
-# __dir__() added in py2.6
-SUPPORTS_DIR_METHOD = not PY_MAX_25 and not (PYPY and sys.pypy_version_info < (1,6))
+if hasattr(sys, "pypy_version_info") and sys.pypy_version_info < (2,0):
+ raise AssertionError("passlib requires pypy >= 2.0 (as of passlib 1.7)")
#=============================================================================
# common imports
@@ -44,7 +42,7 @@ def add_doc(obj, doc):
#=============================================================================
__all__ = [
# python versions
- 'PY2', 'PY3', 'PY_MAX_25', 'PY27', 'PY_MIN_32',
+ 'PY2', 'PY3', 'PY26',
# io
'BytesIO', 'StringIO', 'NativeStringIO', 'SafeConfigParser',
@@ -52,14 +50,14 @@ __all__ = [
# type detection
## 'is_mapping',
- 'callable',
'int_types',
'num_types',
- 'base_string_types',
+ 'unicode_or_bytes_types',
+ 'native_string_types',
# unicode/bytes types & helpers
- 'u', 'b',
- 'unicode', 'bytes',
+ 'u',
+ 'unicode',
'uascii_to_str', 'bascii_to_str',
'str_to_uascii', 'str_to_bascii',
'join_unicode', 'join_bytes',
@@ -73,8 +71,11 @@ __all__ = [
'iteritems', 'itervalues',
'next',
+ # collections
+ 'OrderedDict',
+
# introspection
- 'exc_err', 'get_method_function', 'add_doc',
+ 'get_method_function', 'add_doc',
]
# begin accumulating mapping of lazy-loaded attrs,
@@ -86,34 +87,30 @@ _lazy_attrs = dict()
#=============================================================================
if PY3:
unicode = str
- bytes = builtins.bytes
+ # TODO: once we drop python 3.2 support, can use u'' again!
def u(s):
assert isinstance(s, str)
return s
- def b(s):
- assert isinstance(s, str)
- return s.encode("latin-1")
-
- base_string_types = (unicode, bytes)
+ unicode_or_bytes_types = (unicode, bytes)
native_string_types = (unicode,)
else:
unicode = builtins.unicode
- bytes = str if PY_MAX_25 else builtins.bytes
def u(s):
assert isinstance(s, str)
return s.decode("unicode_escape")
- def b(s):
- assert isinstance(s, str)
- return s
-
- base_string_types = basestring
+ unicode_or_bytes_types = (basestring,)
native_string_types = (basestring,)
+# unicode -- unicode type, regardless of python version
+# bytes -- bytes type, regardless of python version
+# unicode_or_bytes_types -- types that text can occur in, whether encoded or not
+# native_string_types -- types that native python strings (dict keys etc) can occur in.
+
#=============================================================================
# unicode & bytes helpers
#=============================================================================
@@ -121,7 +118,7 @@ else:
join_unicode = u('').join
# function to join list of byte strings
-join_bytes = b('').join
+join_bytes = b''.join
if PY3:
def uascii_to_str(s):
@@ -236,8 +233,8 @@ if PY3:
def itervalues(d):
return d.values()
- next_method_attr = "__next__"
-
+ def nextgetter(obj):
+ return obj.__next__
else:
irange = xrange
##lrange = range
@@ -250,20 +247,10 @@ else:
def itervalues(d):
return d.itervalues()
- next_method_attr = "next"
-
-if PY_MAX_25:
- _undef = object()
- def next(itr, default=_undef):
- """compat wrapper for next()"""
- if default is _undef:
- return itr.next()
- try:
- return itr.next()
- except StopIteration:
- return default
-else:
- next = builtins.next
+ def nextgetter(obj):
+ return obj.next
+
+add_doc(nextgetter, "return function that yields successive values from iterable")
#=============================================================================
# typing
@@ -272,21 +259,9 @@ else:
## # 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
- from collections import Callable
- def callable(obj):
- return isinstance(obj, Callable)
-else:
- callable = builtins.callable
-
#=============================================================================
# introspection
#=============================================================================
-def exc_err():
- """return current error object (to avoid try/except syntax change)"""
- return sys.exc_info()[1]
-
if PY3:
method_function_attr = "__func__"
else:
@@ -296,6 +271,10 @@ def get_method_function(func):
"""given (potential) method, return underlying function"""
return getattr(func, method_function_attr, func)
+def get_unbound_method_function(func):
+ """given unbound method, return underlying function"""
+ return func if PY3 else func.__func__
+
#=============================================================================
# input/output
#=============================================================================
@@ -304,11 +283,8 @@ if PY3:
BytesIO="io.BytesIO",
UnicodeIO="io.StringIO",
NativeStringIO="io.StringIO",
- SafeConfigParser="configparser.SafeConfigParser",
+ SafeConfigParser="configparser.ConfigParser",
)
- if sys.version_info >= (3,2):
- # py32 renamed this, removing old ConfigParser
- _lazy_attrs["SafeConfigParser"] = "configparser.ConfigParser"
print_ = getattr(builtins, "print")
@@ -340,13 +316,13 @@ else:
# pick default end sequence
if end is None:
end = u("\n") if want_unicode else "\n"
- elif not isinstance(end, base_string_types):
+ elif not isinstance(end, unicode_or_bytes_types):
raise TypeError("end must be None or a string")
# pick default separator
if sep is None:
sep = u(" ") if want_unicode else " "
- elif not isinstance(sep, base_string_types):
+ elif not isinstance(sep, unicode_or_bytes_types):
raise TypeError("sep must be None or a string")
# write to buffer
@@ -363,6 +339,14 @@ else:
write(end)
#=============================================================================
+# collections
+#=============================================================================
+if PY26:
+ _lazy_attrs['OrderedDict'] = 'passlib.utils.compat._ordered_dict.OrderedDict'
+else:
+ _lazy_attrs['OrderedDict'] = 'collections.OrderedDict'
+
+#=============================================================================
# lazy overlay module
#=============================================================================
from types import ModuleType
diff --git a/passlib/utils/compat/_ordered_dict.py b/passlib/utils/compat/_ordered_dict.py
new file mode 100644
index 0000000..cfd766d
--- /dev/null
+++ b/passlib/utils/compat/_ordered_dict.py
@@ -0,0 +1,242 @@
+"""passlib.utils.compat._ordered_dict -- backport of collections.OrderedDict for py26
+
+taken from stdlib-suggested recipe at http://code.activestate.com/recipes/576693/
+
+this should be imported from passlib.utils.compat.OrderedDict, not here.
+"""
+
+try:
+ from thread import get_ident as _get_ident
+except ImportError:
+ from dummy_thread import get_ident as _get_ident
+
+class OrderedDict(dict):
+ """Dictionary that remembers insertion order"""
+ # An inherited dict maps keys to values.
+ # The inherited dict provides __getitem__, __len__, __contains__, and get.
+ # The remaining methods are order-aware.
+ # Big-O running times for all methods are the same as for regular dictionaries.
+
+ # The internal self.__map dictionary maps keys to links in a doubly linked list.
+ # The circular doubly linked list starts and ends with a sentinel element.
+ # The sentinel element never gets deleted (this simplifies the algorithm).
+ # Each link is stored as a list of length three: [PREV, NEXT, KEY].
+
+ def __init__(self, *args, **kwds):
+ '''Initialize an ordered dictionary. Signature is the same as for
+ regular dictionaries, but keyword arguments are not recommended
+ because their insertion order is arbitrary.
+
+ '''
+ if len(args) > 1:
+ raise TypeError('expected at most 1 arguments, got %d' % len(args))
+ try:
+ self.__root
+ except AttributeError:
+ self.__root = root = [] # sentinel node
+ root[:] = [root, root, None]
+ self.__map = {}
+ self.__update(*args, **kwds)
+
+ def __setitem__(self, key, value, dict_setitem=dict.__setitem__):
+ 'od.__setitem__(i, y) <==> od[i]=y'
+ # Setting a new item creates a new link which goes at the end of the linked
+ # list, and the inherited dictionary is updated with the new key/value pair.
+ if key not in self:
+ root = self.__root
+ last = root[0]
+ last[1] = root[0] = self.__map[key] = [last, root, key]
+ dict_setitem(self, key, value)
+
+ def __delitem__(self, key, dict_delitem=dict.__delitem__):
+ 'od.__delitem__(y) <==> del od[y]'
+ # Deleting an existing item uses self.__map to find the link which is
+ # then removed by updating the links in the predecessor and successor nodes.
+ dict_delitem(self, key)
+ link_prev, link_next, key = self.__map.pop(key)
+ link_prev[1] = link_next
+ link_next[0] = link_prev
+
+ def __iter__(self):
+ 'od.__iter__() <==> iter(od)'
+ root = self.__root
+ curr = root[1]
+ while curr is not root:
+ yield curr[2]
+ curr = curr[1]
+
+ def __reversed__(self):
+ 'od.__reversed__() <==> reversed(od)'
+ root = self.__root
+ curr = root[0]
+ while curr is not root:
+ yield curr[2]
+ curr = curr[0]
+
+ def clear(self):
+ 'od.clear() -> None. Remove all items from od.'
+ try:
+ for node in self.__map.itervalues():
+ del node[:]
+ root = self.__root
+ root[:] = [root, root, None]
+ self.__map.clear()
+ except AttributeError:
+ pass
+ dict.clear(self)
+
+ def popitem(self, last=True):
+ '''od.popitem() -> (k, v), return and remove a (key, value) pair.
+ Pairs are returned in LIFO order if last is true or FIFO order if false.
+
+ '''
+ if not self:
+ raise KeyError('dictionary is empty')
+ root = self.__root
+ if last:
+ link = root[0]
+ link_prev = link[0]
+ link_prev[1] = root
+ root[0] = link_prev
+ else:
+ link = root[1]
+ link_next = link[1]
+ root[1] = link_next
+ link_next[0] = root
+ key = link[2]
+ del self.__map[key]
+ value = dict.pop(self, key)
+ return key, value
+
+ # -- the following methods do not depend on the internal structure --
+
+ def keys(self):
+ 'od.keys() -> list of keys in od'
+ return list(self)
+
+ def values(self):
+ 'od.values() -> list of values in od'
+ return [self[key] for key in self]
+
+ def items(self):
+ 'od.items() -> list of (key, value) pairs in od'
+ return [(key, self[key]) for key in self]
+
+ def iterkeys(self):
+ 'od.iterkeys() -> an iterator over the keys in od'
+ return iter(self)
+
+ def itervalues(self):
+ 'od.itervalues -> an iterator over the values in od'
+ for k in self:
+ yield self[k]
+
+ def iteritems(self):
+ 'od.iteritems -> an iterator over the (key, value) items in od'
+ for k in self:
+ yield (k, self[k])
+
+ def update(*args, **kwds):
+ '''od.update(E, **F) -> None. Update od from dict/iterable E and F.
+
+ If E is a dict instance, does: for k in E: od[k] = E[k]
+ If E has a .keys() method, does: for k in E.keys(): od[k] = E[k]
+ Or if E is an iterable of items, does: for k, v in E: od[k] = v
+ In either case, this is followed by: for k, v in F.items(): od[k] = v
+
+ '''
+ if len(args) > 2:
+ raise TypeError('update() takes at most 2 positional '
+ 'arguments (%d given)' % (len(args),))
+ elif not args:
+ raise TypeError('update() takes at least 1 argument (0 given)')
+ self = args[0]
+ # Make progressively weaker assumptions about "other"
+ other = ()
+ if len(args) == 2:
+ other = args[1]
+ if isinstance(other, dict):
+ for key in other:
+ self[key] = other[key]
+ elif hasattr(other, 'keys'):
+ for key in other.keys():
+ self[key] = other[key]
+ else:
+ for key, value in other:
+ self[key] = value
+ for key, value in kwds.items():
+ self[key] = value
+
+ __update = update # let subclasses override update without breaking __init__
+
+ __marker = object()
+
+ def pop(self, key, default=__marker):
+ '''od.pop(k[,d]) -> v, remove specified key and return the corresponding value.
+ If key is not found, d is returned if given, otherwise KeyError is raised.
+
+ '''
+ if key in self:
+ result = self[key]
+ del self[key]
+ return result
+ if default is self.__marker:
+ raise KeyError(key)
+ return default
+
+ def setdefault(self, key, default=None):
+ 'od.setdefault(k[,d]) -> od.get(k,d), also set od[k]=d if k not in od'
+ if key in self:
+ return self[key]
+ self[key] = default
+ return default
+
+ def __repr__(self, _repr_running={}):
+ 'od.__repr__() <==> repr(od)'
+ call_key = id(self), _get_ident()
+ if call_key in _repr_running:
+ return '...'
+ _repr_running[call_key] = 1
+ try:
+ if not self:
+ return '%s()' % (self.__class__.__name__,)
+ return '%s(%r)' % (self.__class__.__name__, self.items())
+ finally:
+ del _repr_running[call_key]
+
+ def __reduce__(self):
+ 'Return state information for pickling'
+ items = [[k, self[k]] for k in self]
+ inst_dict = vars(self).copy()
+ for k in vars(OrderedDict()):
+ inst_dict.pop(k, None)
+ if inst_dict:
+ return (self.__class__, (items,), inst_dict)
+ return self.__class__, (items,)
+
+ def copy(self):
+ 'od.copy() -> a shallow copy of od'
+ return self.__class__(self)
+
+ @classmethod
+ def fromkeys(cls, iterable, value=None):
+ '''OD.fromkeys(S[, v]) -> New ordered dictionary with keys from S
+ and values equal to v (which defaults to None).
+
+ '''
+ d = cls()
+ for key in iterable:
+ d[key] = value
+ return d
+
+ def __eq__(self, other):
+ '''od.__eq__(y) <==> od==y. Comparison to another OD is order-sensitive
+ while comparison to a regular mapping is order-insensitive.
+
+ '''
+ if isinstance(other, OrderedDict):
+ return len(self)==len(other) and self.items() == other.items()
+ return dict.__eq__(self, other)
+
+ def __ne__(self, other):
+ return not self == other
diff --git a/passlib/utils/des.py b/passlib/utils/des.py
index a2fc2bf..1a7985a 100644
--- a/passlib/utils/des.py
+++ b/passlib/utils/des.py
@@ -47,8 +47,8 @@ The netbsd des-crypt implementation has some nice notes on how this all works -
import struct
# pkg
from passlib import exc
-from passlib.utils.compat import bytes, join_byte_values, byte_elem_value, \
- b, irange, irange, int_types
+from passlib.utils.compat import join_byte_values, byte_elem_value, \
+ irange, irange, int_types
from passlib.utils import deprecated_function
# local
__all__ = [
@@ -584,7 +584,7 @@ def _permute(c, p):
#=============================================================================
_uint64_struct = struct.Struct(">Q")
-_BNULL = b('\x00')
+_BNULL = b'\x00'
def _pack64(value):
return _uint64_struct.pack(value)
diff --git a/passlib/utils/handlers.py b/passlib/utils/handlers.py
index b25d203..bcaaf8d 100644
--- a/passlib/utils/handlers.py
+++ b/passlib/utils/handlers.py
@@ -4,12 +4,8 @@
#=============================================================================
from __future__ import with_statement
# core
-import inspect
-import re
-import hashlib
import logging; log = logging.getLogger(__name__)
-import time
-import os
+import math
from warnings import warn
# site
# pkg
@@ -22,9 +18,9 @@ from passlib.utils import classproperty, consteq, getrandstr, getrandbytes,\
BASE64_CHARS, HASH64_CHARS, rng, to_native_str, \
is_crypt_handler, to_unicode, \
MAX_PASSWORD_SIZE
-from passlib.utils.compat import b, join_byte_values, bytes, irange, u, \
+from passlib.utils.compat import join_byte_values, irange, u, native_string_types, \
uascii_to_str, join_unicode, unicode, str_to_uascii, \
- join_unicode, base_string_types, PY2, int_types
+ join_unicode, unicode_or_bytes_types, PY2, int_types
# local
__all__ = [
# helpers for implementing MCF handlers
@@ -89,7 +85,7 @@ _UZERO = u("0")
def validate_secret(secret):
"""ensure secret has correct type & size"""
- if not isinstance(secret, base_string_types):
+ if not isinstance(secret, unicode_or_bytes_types):
raise exc.ExpectedStringError(secret, "secret")
if len(secret) > MAX_PASSWORD_SIZE:
raise exc.PasswordSizeError()
@@ -390,6 +386,9 @@ class GenericHandler(PasswordHash):
# private flag used by HasRawChecksum
_checksum_is_bytes = False
+ #: private flag used by using() constructor to detect if this is already a subclass.
+ _configured = False
+
#===================================================================
# instance attrs
#===================================================================
@@ -398,6 +397,21 @@ class GenericHandler(PasswordHash):
# relaxed = False # when _norm_xxx() funcs should be strict about inputs
#===================================================================
+ # configuration interface
+ #===================================================================
+
+ @classmethod
+ def using(cls):
+ # NOTE: this provides the base implementation, which takes care of
+ # creating the newly configured class. Mixins and subclasses
+ # should wrap this, and modify the returned class to suit their options.
+ name = cls.__name__
+ if not cls._configured:
+ # TODO: straighten out class naming, repr, and .name attr
+ name = "<customized %s hasher>" % name
+ return type(name, (cls,), dict(__module__=cls.__module__, _configured=True))
+
+ #===================================================================
# init
#===================================================================
def __init__(self, checksum=None, use_defaults=False, relaxed=False,
@@ -571,6 +585,26 @@ class GenericHandler(PasswordHash):
return consteq(self._calc_checksum(secret), chk)
#===================================================================
+ # migration interface (basde implementation)
+ #===================================================================
+
+ @classmethod
+ def needs_update(cls, hash, secret=None, **kwds):
+ # NOTE: subclasses should generally just wrap _calc_needs_update()
+ # to check their particular keywords.
+ self = cls.from_string(hash)
+ assert isinstance(self, cls)
+ return self._calc_needs_update(secret=secret, **kwds)
+
+ def _calc_needs_update(self, secret=None):
+ """
+ internal helper for :meth:`needs_update`.
+ """
+ # NOTE: this just provides a stub, subclasses & mixins
+ # should override this with their own tests.
+ return False
+
+ #===================================================================
# experimental - the following methods are not finished or tested,
# but way work correctly for some hashes
#===================================================================
@@ -821,6 +855,10 @@ class HasManyIdents(GenericHandler):
.. todo::
document this class's usage
+
+ Class Methods
+ =============
+ .. todo:: document using() and needs_update() options
"""
#===================================================================
@@ -842,6 +880,35 @@ class HasManyIdents(GenericHandler):
ident = None
#===================================================================
+ # variant constructor
+ #===================================================================
+ @classmethod
+ def using(cls, # keyword only...
+ default_ident=None, ident=None, **kwds):
+ """
+ This mixin adds support for the following :meth:`~passlib.ifc.PasswordHash.using` keywords:
+
+ :param default_ident:
+ default identifier that will be used by resulting customized hasher.
+
+ :param ident:
+ supported as alternate alias for **default_ident**.
+ """
+ # resolve aliases
+ if ident is not None:
+ if default_ident is not None:
+ raise TypeError("'default_ident' and 'ident' are mutually exclusive")
+ default_ident = ident
+
+ # create subclass
+ subcls = super(HasManyIdents, cls).using(**kwds)
+
+ # add custom default ident
+ if default_ident is not None:
+ subcls.default_ident = cls(ident=default_ident, use_defaults=True).ident
+ return subcls
+
+ #===================================================================
# init
#===================================================================
def __init__(self, ident=None, **kwds):
@@ -849,14 +916,18 @@ class HasManyIdents(GenericHandler):
self.ident = self._norm_ident(ident)
def _norm_ident(self, ident):
- # fill in default identifier
+ """
+ helper which normalizes & validates 'ident' value.
+ """
+ # fill in default_ident if needed
if ident is None:
if not self.use_defaults:
raise TypeError("no ident specified")
ident = self.default_ident
assert ident is not None, "class must define default_ident"
- # handle unicode
+ # handle bytes
+ assert ident is not None
if isinstance(ident, bytes):
ident = ident.decode('ascii')
@@ -896,6 +967,8 @@ class HasManyIdents(GenericHandler):
return ident, hash[len(ident):]
raise exc.InvalidHashError(cls)
+ # XXX: implement a needs_update() helper that marks everything but default_ident as deprecated?
+
#===================================================================
# eoc
#===================================================================
@@ -998,6 +1071,8 @@ class HasSalt(GenericHandler):
_salt_is_bytes = False
_salt_unit = "chars"
+ # TODO: could support using(min/max_desired_salt_size) via using() and needs_update()
+
#===================================================================
# instance attrs
#===================================================================
@@ -1171,6 +1246,10 @@ class HasRounds(GenericHandler):
(the default) or ``"log2"``, depending on how the rounds value relates
to the actual amount of time that will be required.
+ Class Methods
+ =============
+ .. todo:: document using() and needs_update() options
+
Instance Attributes
===================
.. attribute:: rounds
@@ -1185,17 +1264,236 @@ class HasRounds(GenericHandler):
#===================================================================
# class attrs
#===================================================================
+
+ #-----------------
+ # algorithm options -- not application configurable
+ #-----------------
+ # XXX: rename to min_valid_rounds / max_valid_rounds,
+ # to clarify role compared to min_desired_rounds / max_desired_rounds?
min_rounds = 0
max_rounds = None
- default_rounds = None
rounds_cost = "linear" # default to the common case
+ # hack to pass info to _CryptRecord
+ using_rounds_kwds = ("min_desired_rounds", "max_desired_rounds",
+ "min_rounds", "max_rounds",
+ "default_rounds", "vary_rounds")
+
+ #-----------------
+ # desired & default rounds -- configurable via .using() classmethod
+ #-----------------
+ min_desired_rounds = None
+ max_desired_rounds = None
+ default_rounds = None
+ vary_rounds = None
+
#===================================================================
# instance attrs
#===================================================================
rounds = None
#===================================================================
+ # variant constructor
+ #===================================================================
+ @classmethod
+ def using(cls, # keyword only...
+ min_desired_rounds=None, max_desired_rounds=None,
+ default_rounds=None, vary_rounds=None,
+ min_rounds=None, max_rounds=None, rounds=None, # aliases used by CryptContext
+ **kwds):
+
+ # check for aliases used by CryptContext
+ if min_rounds is not None:
+ if min_desired_rounds is not None:
+ raise TypeError("'min_rounds' and 'min_desired_rounds' aliases are mutually exclusive")
+ min_desired_rounds = min_rounds
+
+ if max_rounds is not None:
+ if max_desired_rounds is not None:
+ raise TypeError("'max_rounds' and 'max_desired_rounds' aliases are mutually exclusive")
+ max_desired_rounds = max_rounds
+
+ if rounds is not None:
+ if default_rounds is not None:
+ raise TypeError("'rounds' and 'default_rounds' aliases are mutually exclusive")
+ default_rounds = rounds
+
+ # generate new subclass
+ subcls = super(HasRounds, cls).using(**kwds)
+
+ # replace min_desired_rounds
+ if min_desired_rounds is None:
+ explicit_min_rounds = False
+ min_desired_rounds = cls.min_desired_rounds
+ else:
+ explicit_min_rounds = True
+ if isinstance(min_desired_rounds, native_string_types):
+ min_desired_rounds = int(min_desired_rounds)
+ if min_desired_rounds < 0:
+ raise ValueError("%s: min_desired_rounds (%r) below 0" %
+ (subcls.name, min_desired_rounds))
+ subcls.min_desired_rounds = subcls._clip_to_valid_rounds(min_desired_rounds,
+ param="min_desired_rounds")
+
+ # replace max_desired_rounds
+ if max_desired_rounds is None:
+ explicit_max_rounds = False
+ max_desired_rounds = cls.max_desired_rounds
+ else:
+ explicit_max_rounds = True
+ if isinstance(max_desired_rounds, native_string_types):
+ max_desired_rounds = int(max_desired_rounds)
+ if min_desired_rounds and max_desired_rounds < min_desired_rounds:
+ msg = "%s: max_desired_rounds (%r) below min_desired_rounds (%r)" % \
+ (subcls.name, max_desired_rounds, min_desired_rounds)
+ if explicit_min_rounds:
+ raise ValueError(msg)
+ else:
+ warn(msg, PasslibConfigWarning)
+ max_desired_rounds = min_desired_rounds
+ elif max_desired_rounds < 0:
+ raise ValueError("%s: max_desired_rounds (%r) below 0" %
+ (subcls.name, max_desired_rounds))
+ subcls.max_desired_rounds = subcls._clip_to_valid_rounds(max_desired_rounds,
+ param="max_desired_rounds")
+
+ # replace default_rounds
+ if default_rounds is not None:
+ if isinstance(default_rounds, native_string_types):
+ default_rounds = int(default_rounds)
+ if min_desired_rounds and default_rounds < min_desired_rounds:
+ raise ValueError("%s: default_rounds (%r) below min_desired_rounds (%r)" %
+ (subcls.name, default_rounds, min_desired_rounds))
+ elif max_desired_rounds and default_rounds > max_desired_rounds:
+ raise ValueError("%s: default_rounds (%r) above max_desired_rounds (%r)" %
+ (subcls.name, default_rounds, max_desired_rounds))
+ subcls.default_rounds = subcls._clip_to_valid_rounds(default_rounds,
+ param="default_rounds")
+
+ # clip default rounds to new limits.
+ if subcls.default_rounds is not None:
+ subcls.default_rounds = subcls._clip_to_desired_rounds(subcls.default_rounds)
+
+ # replace / set vary_rounds
+ if vary_rounds is not None:
+ if isinstance(vary_rounds, native_string_types):
+ if vary_rounds.endswith("%"):
+ vary_rounds = float(vary_rounds[:-1]) * 0.01
+ else:
+ vary_rounds = int(vary_rounds)
+ if vary_rounds < 0:
+ raise ValueError("%s: vary_rounds (%r) below 0" %
+ (subcls.name, vary_rounds))
+ elif isinstance(vary_rounds, float):
+ # TODO: deprecate / disallow vary_rounds=1.0
+ if vary_rounds > 1:
+ raise ValueError("%s: vary_rounds (%r) above 1.0" %
+ (subcls.name, vary_rounds))
+ elif not isinstance(vary_rounds, int):
+ raise TypeError("vary_rounds must be int or float")
+ subcls.vary_rounds = vary_rounds
+ # XXX: could cache _calc_vary_rounds_range() here if needed,
+ # but would need to handle user manually changing .default_rounds
+ return subcls
+
+ @classmethod
+ def _clip_to_valid_rounds(cls, rounds, param="rounds", relaxed=True):
+ """
+ internal helper --
+ clip rounds value to handler's absolute limits (min_rounds / max_rounds)
+
+ :param relaxed:
+ if ``True`` (the default), issues PasslibHashWarning is rounds are outside allowed range.
+ if ``False``, raises a ValueError instead.
+
+ :param param:
+ optional name of parameter to insert into error/warning messages.
+
+ :returns:
+ clipped rounds value
+ """
+ # check minimum
+ mn = cls.min_rounds
+ if rounds < mn:
+ msg = "%s: %s (%r) below min_rounds (%d)" % (cls.name, param, rounds, mn)
+ if relaxed:
+ warn(msg, PasslibHashWarning)
+ rounds = mn
+ else:
+ raise ValueError(msg)
+
+ # check maximum
+ mx = cls.max_rounds
+ if mx and rounds > mx:
+ msg = "%s: %s (%r) above max_rounds (%d)" % (cls.name, param, rounds, mx)
+ if relaxed:
+ warn(msg, PasslibHashWarning)
+ rounds = mx
+ else:
+ raise ValueError(msg)
+
+ return rounds
+
+ @classmethod
+ def _clip_to_desired_rounds(cls, rounds):
+ """
+ helper for :meth:`_generate_rounds` --
+ clips rounds value to desired min/max set by class (if any)
+ """
+ # NOTE: min/max_desired_rounds are None if unset.
+ # check minimum
+ mnd = cls.min_desired_rounds or 0
+ if rounds < mnd:
+ return mnd
+
+ # check maximum
+ mxd = cls.max_desired_rounds
+ if mxd and rounds > mxd:
+ return mxd
+
+ return rounds
+
+ @classmethod
+ def _calc_vary_rounds_range(cls, default_rounds):
+ """
+ helper for :meth:`_generate_rounds` --
+ returns range for vary rounds generation.
+
+ :returns:
+ (lower, upper) limits suitable for random.randint()
+ """
+ # XXX: could precalculate output of this in using() method, and save per-hash cost.
+ # but then users patching cls.vary_rounds / cls.default_rounds would get wrong value.
+ assert default_rounds
+ vary_rounds = cls.vary_rounds
+
+ # if vary_rounds specified as % of default, convert it to actual rounds
+ def linear_to_native(value, upper):
+ return value
+ if isinstance(vary_rounds, float):
+ assert 0 <= vary_rounds <= 1 # TODO: deprecate vary_rounds==1
+ if cls.rounds_cost == "log2":
+ # special case -- have to convert default_rounds to linear scale,
+ # apply +/- vary_rounds to that, and convert back to log scale again.
+ # linear_to_native() takes care of the "convert back" step.
+ default_rounds = 1 << default_rounds
+ def linear_to_native(value, upper):
+ if value <= 0: # log() undefined for <= 0
+ return 0
+ elif upper: # use smallest upper bound for start of range
+ return int(math.log(value, 2))
+ else: # use greatest lower bound for end of range
+ return int(math.ceil(math.log(value, 2)))
+ # calculate integer vary rounds based on current default_rounds
+ vary_rounds = int(default_rounds * vary_rounds)
+
+ # calculate bounds based on default_rounds +/- vary_rounds
+ assert vary_rounds >= 0 and isinstance(vary_rounds, int_types)
+ lower = linear_to_native(default_rounds - vary_rounds, False)
+ upper = linear_to_native(default_rounds + vary_rounds, True)
+ return cls._clip_to_desired_rounds(lower), cls._clip_to_desired_rounds(upper)
+
+ #===================================================================
# init
#===================================================================
def __init__(self, rounds=None, **kwds):
@@ -1203,10 +1501,10 @@ class HasRounds(GenericHandler):
self.rounds = self._norm_rounds(rounds)
def _norm_rounds(self, rounds):
- """helper routine for normalizing rounds
-
- :arg rounds: ``None``, or integer cost parameter.
+ """
+ helper for normalizing rounds value.
+ :arg rounds: ``None``, or an integer cost parameter.
:raises TypeError:
* if ``use_defaults=False`` and no rounds is specified
@@ -1223,40 +1521,79 @@ class HasRounds(GenericHandler):
:returns:
normalized rounds value
"""
- # fill in default
+
+ # init rounds attr, using default_rounds (etc) if needed
+ explicit = False
if rounds is None:
if not self.use_defaults:
raise TypeError("no rounds specified")
- rounds = self.default_rounds
- if rounds is None:
- raise TypeError("%s rounds value must be specified explicitly"
- % (self.name,))
+ rounds = self._generate_rounds() # NOTE: will throw ValueError if default not set
+ assert isinstance(rounds, int_types)
+ elif self.use_defaults:
+ # warn if rounds is outside desired bounds only if user provided explicit rounds
+ # to .encrypt() -- hence the .use_defaults check, which will be false if we're
+ # coming from .verify() / .genhash()
+ explicit = True
# check type
if not isinstance(rounds, int_types):
raise exc.ExpectedTypeError(rounds, "integer", "rounds")
- # check bounds
- mn = self.min_rounds
- if rounds < mn:
- msg = "rounds too low (%s requires >= %d rounds)" % (self.name, mn)
- if self.relaxed:
- warn(msg, PasslibHashWarning)
- rounds = mn
- else:
- raise ValueError(msg)
+ # check valid bounds
+ rounds = self._clip_to_valid_rounds(rounds, relaxed=self.relaxed)
- mx = self.max_rounds
- if mx and rounds > mx:
- msg = "rounds too high (%s requires <= %d rounds)" % (self.name, mx)
- if self.relaxed:
- warn(msg, PasslibHashWarning)
- rounds = mx
- else:
- raise ValueError(msg)
+ # if rounds explicitly specified, warn if outside desired bounds, but use it
+ if explicit:
+ mnd = self.min_desired_rounds
+ if mnd and rounds < mnd:
+ warn("using rounds value (%r) below desired minimum (%d)" % (rounds, mnd),
+ exc.PasslibConfigWarning)
+
+ mxd = self.max_desired_rounds
+ if mxd and rounds > mxd:
+ warn("using rounds value (%r) above desired maximum (%d)" % (rounds, mxd),
+ exc.PasslibConfigWarning)
+ return rounds
+
+ def _generate_rounds(self):
+ """
+ internal helper for :meth:`_norm_rounds` --
+ returns default rounds value, incorporating vary_rounds,
+ and any other limitations hash may place on rounds parameter.
+ """
+ # load default rounds
+ rounds = self.default_rounds
+ if rounds is None:
+ raise TypeError("%s rounds value must be specified explicitly" % (self.name,))
+
+ # randomly vary the rounds slightly basic on vary_rounds parameter.
+ # reads default_rounds internally.
+ if self.vary_rounds:
+ lower, upper = self._calc_vary_rounds_range(rounds)
+ assert lower <= rounds <= upper
+ if lower < upper:
+ rounds = rng.randint(lower, upper)
return rounds
+ #===================================================================
+ # migration interface
+ #===================================================================
+ def _calc_needs_update(self, **kwds):
+ """
+ mark hash as needing update if rounds is outside desired bounds.
+ """
+ min_desired_rounds = self.min_desired_rounds
+ if min_desired_rounds and self.rounds < min_desired_rounds:
+ return True
+ max_desired_rounds = self.max_desired_rounds
+ if max_desired_rounds and self.rounds > max_desired_rounds:
+ return True
+ return super(HasRounds, self)._calc_needs_update(**kwds)
+
+ #===================================================================
+ # experimental methods
+ #===================================================================
@classmethod
def bitsize(cls, rounds=None, vary_rounds=.1, **kwds):
"""[experimental method] return info about bitsizes of hash"""
@@ -1305,24 +1642,52 @@ class HasManyBackends(GenericHandler):
offers a way to specify alternate :meth:`_calc_checksum` methods,
and will dynamically chose the best one at runtime.
- Backend Methods
- ---------------
+ Public API
+ ----------
+
+ .. attribute:: backends
+
+ This attribute should be a tuple containing the names of the backends
+ which are supported. Two common names are ``"os_crypt"`` (if backend
+ uses :mod:`crypt`), and ``"builtin"`` (if the backend is a pure-python
+ fallback).
.. automethod:: get_backend
.. automethod:: set_backend
.. automethod:: has_backend
- Subclass Hooks
- --------------
+ .. warning::
+
+ :meth:`set_backend` and :meth:`has_backend` are intended to be called
+ during application startup -- they affect global state, are not threadsafe.
+
+ Private API (Subclass Hooks)
+ ----------------------------
The following attributes and methods should be filled in by the subclass
which is using :class:`HasManyBackends` as a mixin:
- .. attribute:: backends
+ .. attribute:: _load_backend_{name}
- This attribute should be a tuple containing the names of the backends
- which are supported. Two common names are ``"os_crypt"`` (if backend
- uses :mod:`crypt`), and ``"builtin"`` (if the backend is a pure-python
- fallback).
+ private class method that should try to load the specified backend,
+ one of which should be provided for each backend listed in :attr:`backends`.
+
+ * if backend isn't available, it should return ``None``.
+ * if backend is available, it should return a callable
+ which implements :meth:`_calc_checksum`.
+ * it may also do things like import modules, run tests, issue warnings,
+ etc; though it should avoid doing things which would change the operation
+ of other backends (e.g. modify ``cls.default_rounds``).
+
+ .. versionadded:: 1.7
+
+ .. warning::
+
+ Due to the way passlib's internals are arranged,
+ backends should always store stateful data at the class level
+ (not the module level), and be prepared to be called on subclasses
+ which may be set to a different backend from their parent.
+
+ Idempotent module-level data such as lazy imports are fine.
.. attribute:: _has_backend_{name}
@@ -1331,24 +1696,34 @@ class HasManyBackends(GenericHandler):
or ``False``. One of these should be provided by
the subclass for each backend listed in :attr:`backends`.
+ .. deprecated:: 1.7
+
+ use :attr:`_load_backend_{name}` instead.
+ support for this attribute will be removed in Passlib 2.0.
+
.. classmethod:: _calc_checksum_{name}
private class method that should implement :meth:`_calc_checksum`
for a given backend. it will only be called if the backend has
been selected by :meth:`set_backend`. One of these should be provided
by the subclass for each backend listed in :attr:`backends`.
- """
- # NOTE:
- # subclass must provide:
- # * attr 'backends' containing list of known backends (top priority backend first)
- # * attr '_has_backend_xxx' for each backend 'xxx', indicating if backend is available on system
- # * attr '_calc_checksum_xxx' for each backend 'xxx', containing calc_checksum implementation using that backend
+ .. deprecated:: 1.7
+ use :attr:`_load_backend_{name}` instead.
+ this attribute will be ignored in Passlib 2.0.
+ """
backends = None # list of backend names, provided by subclass.
_backend = None # holds currently loaded backend (if any) or None
+ #: optional class-specific text containing suggestion about what to do
+ #: when no backends are available.
+ _no_backend_suggestion = None
+
+ #: flag used by _try_alternate_backend to prevent recursion
+ __tab_active = None
+
@classmethod
def get_backend(cls):
"""return name of currently active backend.
@@ -1360,12 +1735,10 @@ class HasManyBackends(GenericHandler):
:returns: name of active backend
"""
- name = cls._backend
- if not name:
+ if not cls._backend:
cls.set_backend()
- name = cls._backend
- assert name, "set_backend() didn't load any backends"
- return name
+ assert cls._backend, "set_backend() failed to load a default backend"
+ return cls._backend
@classmethod
def has_backend(cls, name="any"):
@@ -1379,21 +1752,25 @@ class HasManyBackends(GenericHandler):
:raises ValueError: if backend name is unknown
:returns:
- ``True`` if backend is currently supported, else ``False``.
+ ``True`` if backend is currently supported,
+ ``False`` if it's not,
+ and ``None`` if it's present, but won't load due to a security issue.
"""
- if name in ("any", "default"):
- if name == "any" and cls._backend:
+ if name == "any" or name == "default":
+ if cls._backend:
+ return True
+ try:
+ cls.set_backend()
return True
- return any(getattr(cls, "_has_backend_" + name)
- for name in cls.backends)
- elif name in cls.backends:
- return getattr(cls, "_has_backend_" + name)
+ except exc.PasslibSecurityError:
+ return None
+ except exc.MissingBackendError:
+ return False
else:
- raise ValueError("unknown backend: %r" % (name,))
-
- @classmethod
- def _no_backends_msg(cls):
- return "no %s backends available" % (cls.name,)
+ try:
+ return cls._load_backend(name) is not None
+ except exc.PasslibSecurityError:
+ return None
@classmethod
def set_backend(cls, name="any"):
@@ -1425,28 +1802,93 @@ class HasManyBackends(GenericHandler):
* ... if ``"any"`` or ``"default"`` was specified,
and *no* backends are currently available.
- :returns:
+ :raises passlib.exc.PasslibSecurityError:
+
+ if ``"any"`` or ``"default"`` was specified,
+ but the only backend available has a PasslibSecurityError.
+ may be raised by the loading code, or by a subclassed set_backend() function.
+ :returns:
The return value of this function should be ignored.
"""
- if name == "any":
- name = cls._backend
- if name:
- return name
- name = "default"
- if name == "default":
+ if name == "any" and cls._backend:
+ # keep active backend
+ return cls._backend
+ elif name == "any" or name == "default":
+ # select default backend
+ failed_name = None
for name in cls.backends:
- if cls.has_backend(name):
+ try:
+ calc = cls._load_backend(name)
+ except exc.PasslibSecurityError:
+ # backend is available, but refuses to load due to security issue.
+ if failed_name is None:
+ failed_name = name
+ continue
+ if calc:
break
+ assert calc is None
else:
- raise exc.MissingBackendError(cls._no_backends_msg())
- elif not cls.has_backend(name):
- raise exc.MissingBackendError("%s backend not available: %r" %
- (cls.name, name))
- cls._calc_checksum_backend = getattr(cls, "_calc_checksum_" + name)
+ if failed_name:
+ # if there was at least one backend, but it had a PasslibSecurityError,
+ # report that to the user rather than MissingBackendError
+ cls._load_backend(failed_name)
+ msg = "%s: no backends available" % cls.name
+ if cls._no_backend_suggestion:
+ msg += cls._no_backend_suggestion
+ raise exc.MissingBackendError(msg)
+ else:
+ # select specific backend
+ calc = cls._load_backend(name)
+ if not calc:
+ assert calc is None
+ raise exc.MissingBackendError("%s: backend not available: %s" %
+ (cls.name, name))
+ # load backend into class
+ assert callable(calc)
+ cls._calc_checksum_backend = calc
cls._backend = name
return name
+ @classmethod
+ def _load_backend(cls, name):
+ """helper used by has_backend() & set_backend(), loads specified backend.
+
+ :raises ValueError: if invalid backend name is provided
+
+ :raises passlib.exc.SecurityError:
+ backend code itself is allowed to raise this if backend is available,
+ but a fatal security issue was found.
+
+ :returns:
+ * ``None`` if backend can't be loaded.
+ * backend-specific ``_calc_checksum()`` callable on success.
+ """
+ # validate name
+ if name not in cls.backends:
+ raise ValueError("%s: unknown backend: %r" % (cls.name, name))
+
+ # new in v1.7: check for _load_backend_xxx() function
+ load = getattr(cls, "_load_backend_" + name, None)
+ if load is not None:
+ assert not hasattr(cls, "_has_backend_" + name), (
+ "%s: can't specify both ._load_backend_%s() "
+ "and ._has_backend_%s" % (cls.name, name, name)
+ )
+ return load()
+
+ # fallback to _has_backend_xxx + _calc_checksum_xxx() style
+ value = getattr(cls, "_has_backend_" + name)
+ warn("%s: support for ._has_backend_%s is deprecated as of Passlib 1.7, "
+ "and will be removed in Passlib 2.0, please implement "
+ "._load_backend_%s() instead" % (cls.name, name, name),
+ DeprecationWarning,
+ )
+ if value:
+ return getattr(cls, "_calc_checksum_" + name)
+ else:
+ return None
+
def _calc_checksum_backend(self, secret):
"""
stub for _calc_checksum_backend(),
@@ -1461,7 +1903,7 @@ class HasManyBackends(GenericHandler):
return self._calc_checksum_backend(secret)
def _calc_checksum(self, secret):
- """wrapper for backend, for common code"""
+ "wrapper for backend, for common code"""
return self._calc_checksum_backend(secret)
#=============================================================================
@@ -1580,9 +2022,11 @@ class PrefixWrapper(object):
return value
# attrs that should be proxied
+ # XXX: change this to proxy everything that doesn't start with "_"?
_proxy_attrs = (
"setting_kwds", "context_kwds",
"default_rounds", "min_rounds", "max_rounds", "rounds_cost",
+ "min_desired_rounds", "max_desired_rounds",
"default_salt_size", "min_salt_size", "max_salt_size",
"salt_chars", "default_salt_chars",
"backends", "has_backend", "get_backend", "set_backend",
@@ -1634,6 +2078,18 @@ class PrefixWrapper(object):
wrapped = self.prefix + hash[len(orig_prefix):]
return uascii_to_str(wrapped)
+ def using(self, **kwds):
+ # generate subclass of wrapped handler
+ subcls = self.wrapped.using(**kwds)
+ if subcls is self.wrapped:
+ return self
+ # then create identical wrapper which wraps the new subclass.
+ return PrefixWrapper(self.name, subcls, prefix=self.prefix, orig_prefix=self.orig_prefix)
+
+ def needs_update(self, hash, **kwds):
+ hash = self._unwrap_hash(hash)
+ return self.wrapped.needs_update(hash, **kwds)
+
def identify(self, hash):
hash = to_unicode_for_identify(hash)
if not hash.startswith(self.prefix):
diff --git a/passlib/utils/md4.py b/passlib/utils/md4.py
index cd067d9..7b34f17 100644
--- a/passlib/utils/md4.py
+++ b/passlib/utils/md4.py
@@ -15,7 +15,7 @@ from binascii import hexlify
import struct
from warnings import warn
# site
-from passlib.utils.compat import b, bytes, bascii_to_str, irange, PY3
+from passlib.utils.compat import bascii_to_str, irange, PY3
# local
__all__ = [ "md4" ]
#=============================================================================
@@ -72,7 +72,7 @@ class md4(object):
def __init__(self, content=None):
self._count = 0
self._state = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]
- self._buf = b('')
+ self._buf = b''
if content:
self.update(content)
@@ -208,7 +208,7 @@ class md4(object):
# then last 8 bytes = msg length in bits
buf = self._buf
msglen = self._count*512 + len(buf)*8
- block = buf + b('\x80') + b('\x00') * ((119-len(buf)) % 64) + \
+ block = buf + b'\x80' + b'\x00' * ((119-len(buf)) % 64) + \
struct.pack("<2I", msglen & MASK_32, (msglen>>32) & MASK_32)
if len(block) == 128:
self._process(block[:64])
@@ -236,7 +236,6 @@ _builtin_md4 = md4
# check if hashlib provides accelarated md4
#=============================================================================
import hashlib
-from passlib.utils import PYPY
def _has_native_md4(): # pragma: no cover -- runtime detection
try:
@@ -247,9 +246,6 @@ def _has_native_md4(): # pragma: no cover -- runtime detection
result = h.hexdigest()
if result == '31d6cfe0d16ae931b73c59d7e0c089c0':
return True
- if PYPY and result == '':
- # workaround for https://bugs.pypy.org/issue957, fixed in PyPy 1.8
- return False
# anything else and we should alert user
from passlib.exc import PasslibRuntimeWarning
warn("native md4 support disabled, sanity check failed!", PasslibRuntimeWarning)
@@ -259,7 +255,7 @@ if _has_native_md4():
# overwrite md4 class w/ hashlib wrapper
def md4(content=None):
"""wrapper for hashlib.new('md4')"""
- return hashlib.new('md4', content or b(''))
+ return hashlib.new('md4', content or b'')
#=============================================================================
# eof
diff --git a/passlib/utils/pbkdf2.py b/passlib/utils/pbkdf2.py
index ee245d6..74dbc0c 100644
--- a/passlib/utils/pbkdf2.py
+++ b/passlib/utils/pbkdf2.py
@@ -14,28 +14,45 @@ from struct import pack
from warnings import warn
# site
try:
- from M2Crypto import EVP as _EVP
+ import M2Crypto.EVP as _EVP
except ImportError:
_EVP = None
+#_EVP = None
# pkg
from passlib.exc import PasslibRuntimeWarning, ExpectedTypeError
from passlib.utils import join_bytes, to_native_str, bytes_to_int, int_to_bytes, join_byte_values
-from passlib.utils.compat import b, bytes, BytesIO, irange, callable, int_types
+from passlib.utils.compat import BytesIO, irange, int_types
# local
__all__ = [
+ # hash utils
+ "norm_hash_name",
+ "get_hash_info",
+
+ # prf utils
"get_prf",
+ "get_keyed_prf",
+
+ # kdfs
"pbkdf1",
"pbkdf2",
]
+def _clear_caches():
+ """unittest helper -- clears get_hash_info() / get_prf() caches"""
+ _ghi_cache.clear()
+ _prf_cache.clear()
+ _nhn_cache.clear()
+
#=============================================================================
# hash helpers
#=============================================================================
-# known hash names
+# indexes into _nhn_hash_names
_nhn_formats = dict(hashlib=0, iana=1)
+
+# known hash names
_nhn_hash_names = [
- # (hashlib/ssl name, iana name or standin, ... other known aliases)
+ # format: (hashlib/ssl name, iana name or standin, other known aliases ...)
# hashes with official IANA-assigned names
# (as of 2012-03 - http://www.iana.org/assignments/hash-function-text-names)
@@ -81,6 +98,8 @@ def norm_hash_name(name, format="hashlib"):
:returns:
Hash name, returned as native :class:`!str`.
+
+ .. versionadded:: 1.6
"""
# check cache
try:
@@ -130,106 +149,178 @@ def norm_hash_name(name, format="hashlib"):
row = _nhn_cache[orig] = (name2, name)
return row[idx]
-# TODO: get_hash() func which wraps norm_hash_name(), hashlib.<attr>, and hashlib.new
+def _get_hash_const(name):
+ """internal helper used by :func:`get_hash_info`"""
+ # typecheck
+ if not isinstance(name, str):
+ raise TypeError("expected digest name")
+
+ # abort on bad values, and on hashlib attrs which aren't hashes
+ if name.startswith("_") or name in ("new", "algorithms"):
+ return None
+
+ # first, check hashlib.<attr> for an efficient constructor
+ try:
+ return getattr(hashlib, name)
+ except AttributeError:
+ pass
+
+ # second, check hashlib.new() in case SSL supports the digest
+ try:
+ # new() should throw ValueError if alg is unknown
+ tmp = hashlib.new(name, b"")
+ except ValueError:
+ pass
+ else:
+ # create wrapper function
+ # XXX: find a better way to just return hash constructor
+ def const(msg=b""):
+ return hashlib.new(name, msg)
+ const.__name__ = "new(%r)" % name
+ const.__module__ = "hashlib"
+ const.__doc__ = "wrapper for %s hash constructor" % name
+ return const
+
+ # third, use md4 fallback if needed
+ if name == "md4":
+ from passlib.utils.md4 import md4
+ return md4
+
+ # finally, give up!
+ return None
+
+# cache for get_hash_const() lookups
+_ghi_cache = {}
+
+def get_hash_info(name):
+ """Lookup hash constructor & stats by name.
+
+ This is a thin wrapper around :mod:`hashlib`. It looks up a hash by name,
+ and returns a hash constructor, along with the digest & block sizes.
+ Calls to this method are cached, making it a lot faster
+ than looking up a hash and then creating a temporary instance
+ in order to read the digest size. Additionally, this function includes
+ workarounds and fallbacks for various VM-specific issues.
+
+ :arg name: hashlib-compatible name of hash function
+
+ :raises ValueError: if hash is unknown or unsupported.
+
+ :returns: `(hash_constructor, digest_size, block_size)`
+
+ .. versionadded:: 1.7
+ """
+ try:
+ return _ghi_cache[name]
+ except KeyError:
+ pass
+ const = _get_hash_const(name)
+ if not const:
+ raise ValueError("unknown hash algorithm: %r" % name)
+ tmp = const()
+ if len(tmp.digest()) != tmp.digest_size:
+ raise RuntimeError("%r constructor failed sanity check" % name)
+ record = _ghi_cache[name] = (const, tmp.digest_size, tmp.block_size)
+ return record
#=============================================================================
-# general prf lookup
+# prf lookup
#=============================================================================
-_BNULL = b('\x00')
-_XY_DIGEST = b(',\x1cb\xe0H\xa5\x82M\xfb>\xd6\x98\xef\x8e\xf9oQ\x85\xa3i')
-_trans_5C = join_byte_values((x ^ 0x5C) for x in irange(256))
-_trans_36 = join_byte_values((x ^ 0x36) for x in irange(256))
+_BNULL = b'\x00'
+
+# sanity check
+_TEST_HMAC_SHA1 = b',\x1cb\xe0H\xa5\x82M\xfb>\xd6\x98\xef\x8e\xf9oQ\x85\xa3i'
+
+# xlat tables used by hmac routines
+_TRANS_5C = join_byte_values((x ^ 0x5C) for x in irange(256))
+_TRANS_36 = join_byte_values((x ^ 0x36) for x in irange(256))
+
+# prefixes for recognizing hmac-{digest} prf names
+_HMAC_PREFIXES = ("hmac_", "hmac-")
+#------------------------------------------------------------------------
+# general prf lookup
+#------------------------------------------------------------------------
def _get_hmac_prf(digest):
- """helper to return HMAC prf for specific digest"""
+ """helper for get_prf() -- returns HMAC-based prf for specified digest"""
+ # helpers
def tag_wrapper(prf):
+ """helper to document generated wrappers"""
prf.__name__ = "hmac_" + digest
prf.__doc__ = ("hmac_%s(key, msg) -> digest;"
" generated by passlib.utils.pbkdf2.get_prf()" %
digest)
- if _EVP and digest == "sha1":
+ # use m2crypto if it's present and supports requested digest
+ if _EVP:
# use m2crypto function directly for sha1, since that's its default digest
+ if digest == "sha1":
+ if _EVP.hmac(b'x', b'y') != _TEST_HMAC_SHA1:
+ # don't expect to ever get here, but just in case
+ raise RuntimeError("M2Crypto.EVP.hmac() failed sanity check")
+ return _EVP.hmac, 20
+
+ # else check if it supports given digest as an option
try:
- result = _EVP.hmac(b('x'),b('y'))
- except ValueError: # pragma: no cover
- pass
- else:
- if result == _XY_DIGEST:
- return _EVP.hmac, 20
- # don't expect to ever get here, but will fall back to pure-python if we do.
- warn("M2Crypto.EVP.HMAC() returned unexpected result " # pragma: no cover -- sanity check
- "during Passlib self-test!", PasslibRuntimeWarning)
- elif _EVP:
- # use m2crypto if it's present and supports requested digest
- try:
- result = _EVP.hmac(b('x'), b('y'), digest)
+ result = _EVP.hmac(b'x', b'y', digest)
except ValueError:
pass
else:
- # it does. so use M2Crypto's hmac & digest code
- hmac_const = _EVP.hmac
+ const = _EVP.hmac
def prf(key, msg):
- return hmac_const(key, msg, digest)
+ return const(key, msg, digest)
digest_size = len(result)
tag_wrapper(prf)
return prf, digest_size
- # fall back to hashlib-based implementation
- digest_const = getattr(hashlib, digest, None)
- if not digest_const:
- raise ValueError("unknown hash algorithm: %r" % (digest,))
- tmp = digest_const()
- block_size = tmp.block_size
+ # fall back to hashlib-based implementation --
+ # this is a simplified version of stdlib's hmac module.
+ const, digest_size, block_size = get_hash_info(digest)
assert block_size >= 16, "unacceptably low block size"
- digest_size = tmp.digest_size
- del tmp
def prf(key, msg):
- # simplified version of stdlib's hmac module
- if len(key) > block_size:
- key = digest_const(key).digest()
- key += _BNULL * (block_size - len(key))
- tmp = digest_const(key.translate(_trans_36) + msg).digest()
- return digest_const(key.translate(_trans_5C) + tmp).digest()
+ klen = len(key)
+ if klen > block_size:
+ key = const(key).digest()
+ klen = digest_size
+ if klen < block_size:
+ key += _BNULL * (block_size - klen)
+ tmp = const(key.translate(_TRANS_36) + msg).digest()
+ return const(key.translate(_TRANS_5C) + tmp).digest()
tag_wrapper(prf)
return prf, digest_size
# cache mapping prf name/func -> (func, digest_size)
_prf_cache = {}
-def _clear_prf_cache():
- """helper for unit tests"""
- _prf_cache.clear()
-
def get_prf(name):
- """lookup pseudo-random family (prf) by name.
+ """Lookup pseudo-random family (PRF) by name.
:arg name:
- this must be the name of a recognized prf.
- currently this only recognizes names with the format
+ This must be the name of a recognized prf.
+ Currently this only recognizes names with the format
:samp:`hmac-{digest}`, where :samp:`{digest}`
is the name of a hash function such as
``md5``, ``sha256``, etc.
- this can also be a callable with the signature
- ``prf(secret, message) -> digest``,
+ This can also be a callable with the signature
+ ``prf_func(secret, message) -> digest``,
in which case it will be returned unchanged.
:raises ValueError: if the name is not known
:raises TypeError: if the name is not a callable or string
:returns:
- a tuple of :samp:`({func}, {digest_size})`.
+ a tuple of :samp:`({prf_func}, {digest_size})`, where:
- * :samp:`{func}` is a function implementing
- the specified prf, and has the signature
- ``func(secret, message) -> digest``.
+ * :samp:`{prf_func}` is a function implementing
+ the specified PRF, and has the signature
+ ``prf_func(secret, message) -> digest``.
* :samp:`{digest_size}` is an integer indicating
the number of bytes the function returns.
- usage example::
+ Usage example::
>>> from passlib.utils.pbkdf2 import get_prf
>>> hmac_sha256, dsize = get_prf("hmac-sha256")
@@ -239,26 +330,94 @@ def get_prf(name):
32
>>> digest = hmac_sha256('password', 'message')
- this function will attempt to return the fastest implementation
- it can find; if M2Crypto is present, and supports the specified prf,
+ This function will attempt to return the fastest implementation
+ it can find. Primarily, if M2Crypto is present, and supports the specified PRF,
:func:`M2Crypto.EVP.hmac` will be used behind the scenes.
"""
global _prf_cache
if name in _prf_cache:
return _prf_cache[name]
if isinstance(name, str):
- if name.startswith("hmac-") or name.startswith("hmac_"):
- retval = _get_hmac_prf(name[5:])
+ if name.startswith(_HMAC_PREFIXES):
+ record = _get_hmac_prf(name[5:])
else:
raise ValueError("unknown prf algorithm: %r" % (name,))
elif callable(name):
# assume it's a callable, use it directly
- digest_size = len(name(b('x'),b('y')))
- retval = (name, digest_size)
+ digest_size = len(name(b'x', b'y'))
+ record = (name, digest_size)
else:
raise ExpectedTypeError(name, "str or callable", "prf name")
- _prf_cache[name] = retval
- return retval
+ _prf_cache[name] = record
+ return record
+
+#------------------------------------------------------------------------
+# keyed prf generation
+#------------------------------------------------------------------------
+
+def _get_keyed_hmac_prf(digest, key):
+ """get_keyed_prf() helper -- returns efficent hmac() function
+ hardcoded with specific digest and key.
+ """
+ # all the following was adapted from stdlib's hmac module
+
+ # resolve digest, get info
+ const, digest_size, block_size = get_hash_info(digest)
+ assert block_size >= 16, "unacceptably low block size"
+
+ # prepare key
+ klen = len(key)
+ if klen > block_size:
+ key = const(key).digest()
+ klen = digest_size
+ if klen < block_size:
+ key += _BNULL * (block_size - klen)
+
+ # return optimized hmac function for given key
+ inner_proto = const(key.translate(_TRANS_36))
+ outer_proto = const(key.translate(_TRANS_5C))
+ def kprf(msg):
+ inner = inner_proto.copy()
+ inner.update(msg)
+ outer = outer_proto.copy()
+ outer.update(inner.digest())
+ return outer.digest()
+
+ ##kprf.__name__ = "keyed_%s_hmac" % digest
+ ##kprf.__doc__ = "keyed %s-hmac function, " \
+ ## "generated by passlib.utils.pbkdf2.get_keyed_prf()" % digest
+ return kprf, digest_size
+
+def get_keyed_prf(name, key):
+ """Lookup psuedo-random function family by name,
+ and return a psuedo-random function bound to a specific key.
+
+ :arg name:
+ name of psuedorandom family.
+ accepts same inputs as :func:`get_prf`.
+
+ :arg key:
+ key encoded as bytes.
+
+ :returns:
+ tuple of :samp:`({bound_prf_func}, {digest_size})`,
+ where function has signature `bound_prf_func(message) -> digest`.
+
+ .. versionadded:: 1.7
+ """
+ # check for optimized functions (common case)
+ if isinstance(name, str) and name.startswith(_HMAC_PREFIXES):
+ return _get_keyed_hmac_prf(name[5:], key)
+
+ # fallback to making a generic wrapper
+ prf, digest_size = get_prf(name)
+ def kprf(message):
+ return prf(key, message)
+
+ ##kprf.__name__ = "keyed_%s" % prf.__name__
+ ##kprf.__doc__ = "keyed %s function, " \
+ ## "generated by passlib.utils.pbkdf2.get_keyed_prf()" % prf.__name__
+ return kprf, digest_size
#=============================================================================
# pbkdf1 support
@@ -281,7 +440,6 @@ def pbkdf1(secret, salt, rounds, keylen=None, hash="sha1"):
This algorithm has been deprecated, new code should use PBKDF2.
Among other limitations, ``keylen`` cannot be larger
than the digest size of the specified hash.
-
"""
# validate secret & salt
if not isinstance(secret, bytes):
@@ -296,45 +454,34 @@ def pbkdf1(secret, salt, rounds, keylen=None, hash="sha1"):
raise ValueError("rounds must be at least 1")
# resolve hash
- try:
- hash_const = getattr(hashlib, hash)
- except AttributeError:
- # check for ssl hash
- # NOTE: if hash unknown, new() will throw ValueError, which we'd just
- # reraise anyways; so instead of checking, we just let it get
- # thrown during first use, below
- # TODO: use builtin md4 class if hashlib doesn't have it.
- def hash_const(msg):
- return hashlib.new(hash, msg)
-
- # prime pbkdf1 loop, get block size
- block = hash_const(secret + salt).digest()
+ const, digest_size, block_size = get_hash_info(hash)
# validate keylen
if keylen is None:
- keylen = len(block)
+ keylen = digest_size
elif not isinstance(keylen, int_types):
raise ExpectedTypeError(keylen, "int or None", "keylen")
elif keylen < 0:
raise ValueError("keylen must be at least 0")
- elif keylen > len(block):
+ elif keylen > digest_size:
raise ValueError("keylength too large for digest: %r > %r" %
- (keylen, len(block)))
+ (keylen, digest_size))
# main pbkdf1 loop
- for _ in irange(rounds-1):
- block = hash_const(block).digest()
+ block = secret + salt
+ for _ in irange(rounds):
+ block = const(block).digest()
return block[:keylen]
#=============================================================================
# pbkdf2
#=============================================================================
-MAX_BLOCKS = 0xffffffff # 2**32-1
-MAX_HMAC_SHA1_KEYLEN = MAX_BLOCKS*20
+
# NOTE: the pbkdf2 spec does not specify a maximum number of rounds.
# however, many of the hashes in passlib are currently clamped
-# at the 32-bit limit, just for sanity. once realistic pbkdf2 rounds
-# start approaching 24 bits, this limit will be raised.
+# at the 32-bit limit, just for sanity. Once realistic pbkdf2 rounds
+# start approaching 24 bits or so, this limit will be raised.
+_MAX_BLOCKS = 0xffffffff # 2**32-1
def pbkdf2(secret, salt, rounds, keylen=None, prf="hmac-sha1"):
"""pkcs#5 password-based key derivation v2.0
@@ -366,46 +513,39 @@ def pbkdf2(secret, salt, rounds, keylen=None, prf="hmac-sha1"):
if rounds < 1:
raise ValueError("rounds must be at least 1")
+ # generated keyed prf helper
+ keyed_prf, digest_size = get_keyed_prf(prf, secret)
+
# validate keylen
- if keylen is not None:
- if not isinstance(keylen, int_types):
- raise ExpectedTypeError(keylen, "int or None", "keylen")
- elif keylen < 0:
- raise ValueError("keylen must be at least 0")
-
- # special case for m2crypto + hmac-sha1
- if prf == "hmac-sha1" and _EVP:
- if keylen is None:
- keylen = 20
- # NOTE: doing check here, because M2crypto won't take 'long' instances
- # (which this is when running under 32bit)
- if keylen > MAX_HMAC_SHA1_KEYLEN:
- raise ValueError("key length too long for digest")
-
- # NOTE: as of 2012-4-4, m2crypto has buffer overflow issue
- # which may cause segfaults if keylen > 32 (EVP_MAX_KEY_LENGTH).
- # therefore we're avoiding m2crypto for large keys until that's fixed.
- # see https://bugzilla.osafoundation.org/show_bug.cgi?id=13052
- if keylen < 32:
- return _EVP.pbkdf2(secret, salt, rounds, keylen)
-
- # resolve prf
- prf_func, digest_size = get_prf(prf)
if keylen is None:
keylen = digest_size
+ elif not isinstance(keylen, int_types):
+ raise ExpectedTypeError(keylen, "int or None", "keylen")
+ elif keylen < 0:
+ raise ValueError("keylen must be at least 0")
+
+ # m2crypto's pbkdf2-hmac-sha1 is faster than ours, so use it if available.
+ # NOTE: as of 2012-4-4, m2crypto has buffer overflow issue which frequently
+ # causes segfaults if keylen > 32 (EVP_MAX_KEY_LENGTH).
+ # therefore we're avoiding m2crypto for large keys until that's fixed.
+ # (https://bugzilla.osafoundation.org/show_bug.cgi?id=13052)
+ if prf == "hmac-sha1" and _EVP and keylen < 32:
+ return _EVP.pbkdf2(secret, salt, rounds, keylen)
- # figure out how many blocks we'll need
- block_count = (keylen+digest_size-1)//digest_size
- if block_count >= MAX_BLOCKS:
- raise ValueError("key length too long for digest")
+ # work out min block count s.t. keylen <= block_count * digest_size
+ block_count = (keylen + digest_size - 1) // digest_size
+ if block_count >= _MAX_BLOCKS:
+ raise ValueError("keylen too long for digest")
# build up result from blocks
def gen():
for i in irange(block_count):
- digest = prf_func(secret, salt + pack(">L", i+1))
+ digest = keyed_prf(salt + pack(">L", i+1))
accum = bytes_to_int(digest)
+ # speed-critical loop of pbkdf2
+ # NOTE: currently converting digests to integers since that XORs faster.
for _ in irange(rounds-1):
- digest = prf_func(secret, digest)
+ digest = keyed_prf(digest)
accum ^= bytes_to_int(digest)
yield int_to_bytes(accum, digest_size)
return join_bytes(gen())[:keylen]
diff --git a/passlib/win32.py b/passlib/win32.py
index fd6febe..bbdcd57 100644
--- a/passlib/win32.py
+++ b/passlib/win32.py
@@ -34,7 +34,7 @@ warn("the 'passlib.win32' module is deprecated, and will be removed in "
from binascii import hexlify
# site
# pkg
-from passlib.utils.compat import b, unicode
+from passlib.utils.compat import unicode
from passlib.utils.des import des_encrypt_block
from passlib.hash import nthash
# local
@@ -46,7 +46,7 @@ __all__ = [
#=============================================================================
# helpers
#=============================================================================
-LM_MAGIC = b("KGS!@#$%")
+LM_MAGIC = b"KGS!@#$%"
raw_nthash = nthash.raw_nthash
@@ -59,7 +59,7 @@ def raw_lmhash(secret, encoding="ascii", hex=False):
# from being made w/o user explicitly choosing an encoding.
if isinstance(secret, unicode):
secret = secret.encode(encoding)
- ns = secret.upper()[:14] + b("\x00") * (14-len(secret))
+ ns = secret.upper()[:14] + b"\x00" * (14-len(secret))
out = des_encrypt_block(ns[:7], LM_MAGIC) + des_encrypt_block(ns[7:], LM_MAGIC)
return hexlify(out).decode("ascii") if hex else out
diff --git a/setup.cfg b/setup.cfg
index ec4301b..4823ac9 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -4,6 +4,6 @@ sign = true
[upload_docs]
upload_dir = build/sphinx/html
-[wheel]
+[bdist_wheel]
universal = 1
diff --git a/setup.py b/setup.py
index 0643e8d..084055a 100644
--- a/setup.py
+++ b/setup.py
@@ -15,12 +15,7 @@ import time
py3k = (sys.version_info[0] >= 3)
-try:
- from setuptools import setup
- has_distribute = True
-except ImportError:
- from distutils.core import setup
- has_distribute = False
+from setuptools import setup
#=============================================================================
# init setup options
@@ -108,7 +103,6 @@ Intended Audience :: Developers
License :: OSI Approved :: BSD License
Natural Language :: English
Operating System :: OS Independent
-Programming Language :: Python :: 2.5
Programming Language :: Python :: 2.6
Programming Language :: Python :: 2.7
Programming Language :: Python :: 3
@@ -144,9 +138,10 @@ setup(
"passlib.tests",
"passlib.utils",
"passlib.utils._blowfish",
+ "passlib.utils.compat",
"passlib._setup",
],
- package_data = { "passlib.tests": ["*.cfg"] },
+ package_data = { "passlib.tests": ["*.cfg"], "passlib":["_data/**"] },
zip_safe=True,
# metadata
diff --git a/tox.ini b/tox.ini
index 3dbfcee..8669ab3 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,6 +2,10 @@
# Passlib configuration for TOX
#===========================================================================
#
+# TODO: rewrite to use tox 1.8's "factors" feature
+# TODO: figure out best way to pass extra args to all nosetest calls.
+# needed for jenkins to pass "--with-xunit --xunit-file=nosetests-{envname}.xml"
+#
#-----------------------------------------------------------------------
# config options
#-----------------------------------------------------------------------
@@ -200,15 +204,11 @@ commands =
#===========================================================================
# Django integration testing
#
-# currently supports Django 1.4 +
+# currently supports Django 1.6+
#
# there are tests for the major django versions at the time of release,
# short the latest version, which is handled by the 'django' test.
#
-# Django 1.4 / 1.5 are only tested under python 2.
-# Django 1.6 + are only tested under python 3,
-# with the exception of the latest version, which is tested under both.
-#
# All django releases are testing with bcrypt installed,
# there is special test which runs w/o bcrypt.
#
@@ -217,37 +217,6 @@ commands =
#===========================================================================
#---------------------------------------------------------------------
-# legacy django versions that are py2 only
-#---------------------------------------------------------------------
-[testenv:django14-py2]
-basepython = python2
-deps =
- {[testenv:py27]deps}
- django<1.5
-commands =
- nosetests {posargs:--randomize passlib.tests.test_ext_django passlib.tests.test_handlers_django}
-
-# NOTE: testing 1.4.5 specifically to detect regression of issue 52, since django < 1.4.6
-# has some slight differences in the hasher classes & tests.
-[testenv:django145-py2]
-basepython = python2
-deps =
- {[testenv:py27]deps}
- django==1.4.5
-commands =
- nosetests {posargs:--randomize passlib.tests.test_ext_django passlib.tests.test_handlers_django}
-
-[testenv:django15-py2]
-basepython = python2
-deps =
- # NOTE: would use py27 deps, but django 1.5 has issue with bcrypt 2.0
- {[testenv:py27]_mindeps}
- py-bcrypt
- django<1.6
-commands =
- nosetests {posargs:--randomize passlib.tests.test_ext_django passlib.tests.test_handlers_django}
-
-#---------------------------------------------------------------------
# legacy django verions that are py2/py3
#---------------------------------------------------------------------
[testenv:django16-py3]