summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2012-01-10 00:23:06 -0500
committerEli Collins <elic@assurancetechnologies.com>2012-01-10 00:23:06 -0500
commit7b64a499169c533ab97501ccb9eeed5d27c6e47c (patch)
tree9b22cc0b60d9d91d3da6ed513eb16e7304694121
parentc42fd113ad3da503415c70008d945e24deb3536a (diff)
parent14c2d65502c576043e8c493a0b9b2265d3d501b0 (diff)
downloadpasslib-7b64a499169c533ab97501ccb9eeed5d27c6e47c.tar.gz
Merge scram-dev: adds hash for storing SCRAM protocol digests
-rw-r--r--CHANGES25
-rw-r--r--docs/lib/passlib.hash.rst1
-rw-r--r--docs/lib/passlib.hash.scram.rst143
-rw-r--r--docs/modular_crypt_format.rst10
-rw-r--r--passlib/context.py22
-rw-r--r--passlib/handlers/bcrypt.py4
-rw-r--r--passlib/handlers/scram.py655
-rw-r--r--passlib/registry.py1
-rw-r--r--passlib/tests/test_handlers.py235
-rw-r--r--passlib/tests/test_utils.py11
-rw-r--r--passlib/utils/compat.py5
-rw-r--r--passlib/utils/handlers.py2
-rw-r--r--passlib/utils/pbkdf2.py10
13 files changed, 1102 insertions, 22 deletions
diff --git a/CHANGES b/CHANGES
index 6f389f6..86f7e06 100644
--- a/CHANGES
+++ b/CHANGES
@@ -6,12 +6,21 @@ Release History
**1.6** (NOT YET RELEASED)
+ Hashes
+
+ * Passlib now offers the :doc:`scram <lib/passlib.hash.scram>` hash,
+ specially designed for storing digest information for verifying
+ a user against the SCRAM protocol (:rfc:`5802`). It can also
+ be used to verify users in the same way as any other password
+ hash in Passlib, though it offers no particular advantages
+ outside of this special case.
+
CryptContext
.. currentmodule:: passlib.context
- * :class:`~CryptContext` now supports a :ref:`passprep` option,
- which runs all passwords through :rfc:`SASLPrep <4013>`
+ * :class:`~CryptContext` now supports a :ref:`passprep <passprep>` option,
+ which runs all passwords through SASLPrep (:rfc:`4013`)
in order to normalize their unicode representation before hashing
[issue 24].
@@ -43,7 +52,9 @@ Release History
Other
* Passlib is now source-compatible with Python 2.5+ and Python 3,
- and no longer requires the use of :cmd:`2to3` to run under Python 3.
+ and no longer requires the use of :command:`2to3` to run under Python 3.
+
+ .. currentmodule:: passlib.hash
.. _consteq-issue:
@@ -59,14 +70,14 @@ Release History
as it requires the attacker to both know the salt,
and be able to generate digests beginning with a specific prefix.
However, while this task should be computationally difficult
- against modern hashes (such as :class:`!sha512_crypt`), this
+ against modern hashes (such as :class:`sha512_crypt`), this
change should pre-emptively protect Passlib in case someone
constructs a such an attack in the future. Furthermore, some of
the legacy hashes supported by Passlib (such as
- :class:`!mysql323`) are already weak enough to be vulnerable.
+ :class:`mysql323`) are already weak enough to be vulnerable.
- * Builtin implementations of :class:`~md5_crypt`,
- :class:`~sha256_crypt`, and :class:`~sha512_crypt` sped up by
+ * Builtin implementations of :class:`md5_crypt`,
+ :class:`sha256_crypt`, and :class:`sha512_crypt` sped up by
about 25% due via additional pre-computation step.
* Restored builtin pure-python BCrypt implementation
diff --git a/docs/lib/passlib.hash.rst b/docs/lib/passlib.hash.rst
index 7c58cfe..fc108ab 100644
--- a/docs/lib/passlib.hash.rst
+++ b/docs/lib/passlib.hash.rst
@@ -94,6 +94,7 @@ they can be used compatibly along side other modular crypt format hashes.
passlib.hash.pbkdf2_digest
passlib.hash.cta_pbkdf2_sha1
passlib.hash.dlitz_pbkdf2_sha1
+ passlib.hash.scram
Special note should be made of the fallback helper,
which is not an actual hash scheme, but provides "disabled account"
diff --git a/docs/lib/passlib.hash.scram.rst b/docs/lib/passlib.hash.scram.rst
new file mode 100644
index 0000000..fff87bf
--- /dev/null
+++ b/docs/lib/passlib.hash.scram.rst
@@ -0,0 +1,143 @@
+===================================================================
+:class:`passlib.hash.scram` - SCRAM Hash
+===================================================================
+
+.. currentmodule:: passlib.hash
+
+SCRAM is a password-based challenge response protocol defined by :rfc:`5802`.
+While Passlib does not provide an implementation of SCRAM, applications
+which use SCRAM on the server side frequently need a way to store
+user passwords in a secure format that can be used to authenticate users over
+SCRAM.
+
+To accomplish this, Passlib provides the following
+:ref:`modular-crypt-format`-compatible password hash scheme which uses the
+``"$scram$"`` identifier. This format encodes a salt, rounds settings, and one
+or more :func:`~passlib.utils.pbkdf2.pbkdf2` digests, one digest for each
+of the hash algorithms the server wishes to support over SCRAM.
+
+Since this format is PBKDF2-based, it has equivalent security to
+Passlib's other :doc:`pbkdf2 hashes <passlib.hash.pbkdf2_digest>`,
+and can be used to authenticate users using either the SCRAM-specific class
+methods documentated below, or the normal :ref:`password-hash-api`.
+
+.. note::
+
+ If you aren't working with the SCRAM protocol, you probably
+ don't need to use this hash format.
+
+Usage
+=====
+This class can be used like any other Passlib hash, as follows::
+
+ >>> from passlib.hash import scram
+
+ >>> #generate new salt, encrypt password against default list of algorithms
+ >>> h = scram.encrypt("password")
+ >>> h # (output split over multiple lines for readability)
+ '$scram$6400$.Z/znnNOKWUsBaCU$sha-1=cRseQyJpnuPGn3e6d6u6JdJWk.0,sha-256=5G\
+ cjEbRaUIIci1r6NAMdI9OPZbxl9S5CFR6la9CHXYc,sha-512=.DHbIm82ajXbFR196Y.9Ttbs\
+ gzvGjbMeuWCtKve8TPjRMNoZK9EGyHQ6y0lW9OtWdHZrDZbBUhB9ou./VI2mlw'
+
+ >>> #same, but with explict number of rounds
+ >>> scram.encrypt("password", rounds=8000)
+ '$scram$8000$Y0zp/R/DeO89h/De$sha-1=eE8dq1f1P1hZm21lfzsr3CMbiEA,sha-256=Nf\
+ kaDFMzn/yHr/HTv7KEFZqaONo6psRu5LBBFLEbZ.o,sha-512=XnGG11X.J2VGSG1qTbkR3FVr\
+ 9j5JwsnV5Fd094uuC.GtVDE087m8e7rGoiVEgXnduL48B2fPsUD9grBjURjkiA'
+
+ >>> #check if hash is recognized
+ >>> scram.identify(h)
+ True
+ >>> #check if some other hash is recognized
+ >>> scram.identify('$1$3azHgidD$SrJPt7B.9rekpmwJwtON31')
+ False
+
+ >>> #verify correct password
+ >>> scram.verify("password", h)
+ True
+ >>> scram.verify("secret", h) #verify incorrect password
+ False
+
+Additionally, this class provides a number of useful methods
+for SCRAM-specific actions::
+
+ >>> from passlib.hash import scram
+ >>> # generate new salt, encrypt password against default list of algorithms
+ >>> scram.encrypt("password")
+ '$scram$6400$.Z/znnNOKWUsBaCU$sha-1=cRseQyJpnuPGn3e6d6u6JdJWk.0,sha-256=5G\
+ cjEbRaUIIci1r6NAMdI9OPZbxl9S5CFR6la9CHXYc,sha-512=.DHbIm82ajXbFR196Y.9Ttbs\
+ gzvGjbMeuWCtKve8TPjRMNoZK9EGyHQ6y0lW9OtWdHZrDZbBUhB9ou./VI2mlw'
+
+ >>> # generate new salt, encrypt password against specific list of algorithms
+ >>> # and choose explicit number of rounds
+ >>> h = scram.encrypt("password", rounds=1000, algs="sha-1,sha-256,md5")
+ >>> h
+ '$scram$1000$RsgZo7T2/l8rBUBI$md5=iKsH555d3ctn795Za4S7bQ,sha-1=dRcE2AUjALLF\
+ tX5DstdLCXZ9Afw,sha-256=WYE/LF7OntriUUdFXIrYE19OY2yL0N5qsQmdPNFn7JE'
+
+ >>> # given a scram hash, retrieve the information SCRAM needs
+ >>> # to authenticate using a specific mechanism -
+ >>> # returns salt, rounds, digest
+ >>> scram.extact_digest_info(h, "sha-1")
+ ('F\xc8\x19\xa3\xb4\xf6\xfe_+\x05@H',
+ 1000,
+ 'u\x17\x04\xd8\x05#\x00\xb2\xc5\xb5~C\xb2\xd7K\tv}\x01\xfc')
+
+ >>> # given a scram hash, return list of digest algs present
+ >>> scram.extract_digest_algs(h)
+ ["md5", "sha-1", "sha-256"]
+
+ >>> # and a standalone helper that can calculate the SaltedPassword
+ >>> # portion of the SCRAM protocol, taking care of SASLPrep as well.
+ >>> scram.derive_digest("password", b'\x01\x02\x03', 1000, "sha-1")
+ b'k\x086vg\xb3\xfciz\xb4\xb4\xe2JRZ\xaet\xe4`\xe7'
+
+Interface
+=========
+.. autoclass:: scram(algs=None, salt=None, rounds=None, strict=False)
+
+.. rst-class:: html-toggle
+
+Format & Algorithm
+==================
+An example scram hash (of the string ``password``) is::
+
+ $scram$6400$.Z/znnNOKWUsBaCU$sha-1=cRseQyJpnuPGn3e6d6u6JdJWk.0,sha-256=5G
+ cjEbRaUIIci1r6NAMdI9OPZbxl9S5CFR6la9CHXYc,sha-512=.DHbIm82ajXbFR196Y.9Ttb
+ sgzvGjbMeuWCtKve8TPjRMNoZK9EGyHQ6y0lW9OtWdHZrDZbBUhB9ou./VI2mlw'
+
+An scram hash string has the format :samp:`$scram${rounds}${salt}${alg1}={digest1},{alg2}={digest2},{...}`, where:
+
+* ``$scram$`` is the prefix used to identify Passlib scram hashes,
+ following the :ref:`modular-crypt-format`
+
+* :samp:`{rounds}` is the decimal number of rounds to use (6400 in the example).
+
+* :samp:`{salt}` is a base64 encoded salt string (``.Z/znnNOKWUsBaCU`` in the example).
+
+* :samp:`{alg}` is a lowercase IANA hash function name, which should
+ match the digest in the SCRAM mechanism name.
+
+* :samp:`{digest}` is a base64 digest for the specific algorithm.
+ Digests for ``sha-1``, ``sha-256``, and ``sha-512`` are present in the example.
+
+* There will be one or more :samp:`{alg}={digest}` pairs, separated by a comma;
+ per the SCRAM specification, the algorithm ``sha-1`` should always be present.
+
+There is also an alternate format :samp:`$scram${rounds}${salt}${alg}{,...}`
+which is used to represent a configuration string that doesn't contain
+any digests.
+
+The algorithm used to calculate each digest is::
+
+ pbkdf2(salsprep(password).encode("utf-8"), salt, rounds, -1, alg)
+
+...as laid out in the SCRAM specification. All digests
+verify against the same password, or the hash should be considered malformed.
+
+.. note::
+
+ This format is similar in spirit to the LDAP storage format for SCRAM hashes,
+ defined in :rfc:`5803`, except that it encodes everything into a single
+ string, and does not have any storage requirements (outside of the ability
+ to store 512+ character ascii strings).
diff --git a/docs/modular_crypt_format.rst b/docs/modular_crypt_format.rst
index 5db4905..bc96ffe 100644
--- a/docs/modular_crypt_format.rst
+++ b/docs/modular_crypt_format.rst
@@ -149,20 +149,20 @@ Scheme Prefix Primary Use (if
=========================================== =================== ===========================
:class:`~passlib.hash.apr_md5_crypt` ``$apr1$`` Apache htdigest files
:class:`~passlib.hash.phpass` ``$P$``, ``$H$`` PHPass-based applications
-:class:`~passlib.hash.pbkdf2_sha1` ``$pbkdf2$``
-:class:`~passlib.hash.pbkdf2_sha256` ``$pbkdf2-sha256$``
-:class:`~passlib.hash.pbkdf2_sha512` ``$pbkdf2-sha512$``
+:class:`~passlib.hash.pbkdf2_sha1` ``$pbkdf2$`` Passlib-specific
+:class:`~passlib.hash.pbkdf2_sha256` ``$pbkdf2-sha256$`` Passlib-specific
+:class:`~passlib.hash.pbkdf2_sha512` ``$pbkdf2-sha512$`` Passlib-specific
+:class:`~passlib.hash.scram` ``$scram$`` Passlib-specific
:class:`~passlib.hash.cta_pbkdf2_sha1` ``$p5k2$`` [#cta]_
:class:`~passlib.hash.dlitz_pbkdf2_sha1` ``$p5k2$`` [#cta]_
=========================================== =================== ===========================
.. rubric:: Footnotes
-.. [#gae] As of 2011-08-19, Google App Engine's :mod:`crypt` implementation
+.. [#gae] As of 2011-08-19, Google App Engine's :mod:`crypt` implementation
appears to provide hash support matching that of a typical Linux system.
.. [#cta] :class:`!cta_pbkdf2_sha1` and :class:`!dlitz_pbkdf2_sha1` both use
the same identifier. They can be distinguished
by the fact that cta hashes will always end in ``=``, while dlitz
hashes contain no ``=`` at all.
-
diff --git a/passlib/context.py b/passlib/context.py
index 48b6b7d..7b99a26 100644
--- a/passlib/context.py
+++ b/passlib/context.py
@@ -1053,8 +1053,20 @@ class _CryptRecord(object):
self.hash_needs_update = lambda hash: True
return
+ # let handler detect hashes with configurations that don't match
+ # current settings. currently do this by calling
+ # ``handler._deprecation_detector(**settings)``, which if defined
+ # should return None or a callable ``is_deprecated(hash)->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
- self._hash_needs_update = getattr(handler, "_hash_needs_update", None)
+ const = getattr(handler, "_deprecation_detector", None)
+ if const:
+ self._hash_needs_update = const(**self._settings)
+
+ # XXX: what about a "min_salt_size" deprecator?
# check if there are rounds, rounds limits, and if we can
# parse the rounds from the handler. if that's the case...
@@ -1064,12 +1076,7 @@ class _CryptRecord(object):
def hash_needs_update(self, hash):
# NOTE: this is replaced by _compile_deprecation() if self.deprecated
- # XXX: could check if handler provides it's own helper, e.g.
- # getattr(handler, "hash_needs_update", None), possibly instead of
- # calling the default check below...
- #
- # NOTE: hacking this in for the sake of bcrypt & issue 25,
- # will formalize (and possibly change) interface later.
+ # check handler's detector if it provided one.
hnu = self._hash_needs_update
if hnu and hnu(hash):
return True
@@ -1354,6 +1361,7 @@ class CryptContext(object):
# since it will have optimized itself for the particular
# settings used within the policy by that (scheme,category).
+ # XXX: would a better name be is_deprecated(hash)?
def hash_needs_update(self, hash, category=None):
"""check if hash is allowed by current policy, or if secret should be re-encrypted.
diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py
index 3e401e8..62d875e 100644
--- a/passlib/handlers/bcrypt.py
+++ b/passlib/handlers/bcrypt.py
@@ -153,6 +153,10 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh.
#=========================================================
@classmethod
+ def _deprecation_detector(cls, **settings):
+ return cls._hash_needs_update
+
+ @classmethod
def _hash_needs_update(cls, hash):
if isinstance(hash, bytes):
hash = hash.decode("ascii")
diff --git a/passlib/handlers/scram.py b/passlib/handlers/scram.py
new file mode 100644
index 0000000..a0c03f0
--- /dev/null
+++ b/passlib/handlers/scram.py
@@ -0,0 +1,655 @@
+"""passlib.handlers.scram - SCRAM hash
+
+Notes
+=====
+Working on hash to format to support storing SCRAM protocol information
+server-side.
+
+passlib issue - https://code.google.com/p/passlib/issues/detail?id=23
+
+scram protocol - http://tools.ietf.org/html/rfc5802
+ http://tools.ietf.org/html/rfc5803
+
+"""
+#=========================================================
+#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
+#libs
+from passlib.utils import adapted_b64_encode, adapted_b64_decode, xor_bytes, \
+ handlers as uh, to_native_str, to_unicode, consteq, saslprep
+from passlib.utils.compat import unicode, bytes, u, b, iteritems, itervalues, \
+ PY2, PY3
+from passlib.utils.pbkdf2 import pbkdf2, get_prf
+#pkg
+#local
+__all__ = [
+ "scram",
+]
+
+#=========================================================
+# helpers
+#=========================================================
+# set of known iana names --
+# http://www.iana.org/assignments/hash-function-text-names
+iana_digests = frozenset(["md2", "md5", "sha-1", "sha-224", "sha-256",
+ "sha-384", "sha-512"])
+
+# cache for norm_digest_name()
+_ndn_cache = {}
+
+def norm_digest_name(name):
+ """normalize digest names to IANA hash function name.
+
+ :arg name:
+ name can be a Python :mod:`~hashlib` digest name,
+ a SCRAM mechanism name, etc; case insensitive.
+
+ input can be either unicode or bytes.
+
+ :returns:
+ native string containing lower-case IANA hash function name.
+ if IANA has not assigned one, this will make a guess as to
+ what the IANA-style representation should be.
+ """
+ # check cache
+ try:
+ return _ndn_cache[name]
+ except KeyError:
+ pass
+ key = name
+
+ # normalize case
+ name = name.strip().lower().replace("_","-")
+
+ # extract digest from scram mechanism name
+ if name.startswith("scram-"):
+ name = name[6:]
+ if name.endswith("-plus"):
+ name = name[:-5]
+
+ # handle some known aliases
+ if name not in iana_digests:
+ if name == "sha1":
+ name = "sha-1"
+ else:
+ m = re.match("^sha2-(\d{3})$", name)
+ if m:
+ name = "sha-" + m.group(1)
+
+ # run heuristics if not an official name
+ if name not in iana_digests:
+
+ # add hyphen between hash name and digest size;
+ # e.g. "ripemd160" -> "ripemd-160"
+ m = re.match("^([a-z]+)(\d{3,4})$", name)
+ if m:
+ name = m.group(1) + "-" + m.group(2)
+
+ # remove hyphen between hash name & version (e.g. MD-5 -> MD5)
+ # note that SHA-1 is an exception to this, but taken care of above.
+ m = re.match("^([a-z]+)-(\d)$", name)
+ if m:
+ name = m.group(1) + m.group(2)
+
+ # check for invalid chars
+ if re.search("[^a-z0-9-]", name):
+ raise ValueError("invalid characters in digest name: %r" % (name,))
+
+ # issue warning if not in the expected format,
+ # this might be a sign of some strange input
+ # (and digest probably won't be found)
+ m = re.match("^([a-z]{2,}\d?)(-\d{3,4})?$", name)
+ if not m:
+ warn("encountered oddly named digest: %r" % (name,))
+
+ # store in cache
+ _ndn_cache[key] = name
+ return name
+
+def iana_to_hashlib(name):
+ "adapt iana hash name -> hashlib hash name"
+ # NOTE: assumes this has been run through norm_digest_name()
+ # XXX: this works for all known cases for now, might change in future.
+ return name.replace("-","")
+
+_gds_cache = {}
+
+def _get_digest_size(name):
+ "get size of digest"
+ try:
+ return _gds_cache[name]
+ except KeyError:
+ pass
+ key = name
+ name = iana_to_hashlib(norm_digest_name(name))
+ value = hashlib.new(name).digest_size
+ _gds_cache[key] = value
+ return value
+
+#=========================================================
+#
+#=========================================================
+class scram(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
+ """This class provides a format for storing SCRAM passwords, and follows
+ the :ref:`password-hash-api`.
+
+ It supports a variable-length salt, and a variable number of rounds.
+
+ The :meth:`encrypt()` and :meth:`genconfig` methods accept the following optional keywords:
+
+ :param salt:
+ Optional salt bytes.
+ If specified, the length must be between 0-1024 bytes.
+ If not specified, a 12 byte salt will be autogenerated
+ (this is recommended).
+
+ :param salt_size:
+ Optional number of bytes to use when autogenerating new salts.
+ Defaults to 12 bytes, but can be any value between 0 and 1024.
+
+ :param rounds:
+ Optional number of rounds to use.
+ Defaults to 6400, but must be within ``range(1,1<<32)``.
+
+ :param algs:
+ Specify list of digest algorithms to use.
+
+ By default each scram hash will contain digests for SHA-1,
+ SHA-256, and SHA-512. This can be overridden by specify either be a
+ list such as ``["sha-1", "sha-256"]``, or a comma-separated string
+ such as ``"sha-1, sha-256"``. Names are case insensitive, and may
+ use :mod:`!hashlib` or `IANA <http://www.iana.org/assignments/hash-function-text-names>`_
+ hash names.
+
+ In addition to the standard :ref:`password-hash-api` methods,
+ this class also provides the following methods for manipulating Passlib
+ scram hashes in ways useful for pluging into a SCRAM protocol stack:
+
+ .. automethod:: extract_digest_info
+ .. automethod:: extract_digest_algs
+ .. automethod:: derive_digest
+ """
+ #=========================================================
+ #class attrs
+ #=========================================================
+
+ # NOTE: unlike most GenericHandler classes, the 'checksum' attr of
+ # ScramHandler is actually a map from digest_name -> digest, so
+ # many of the standard methods have been overridden.
+
+ # NOTE: max_salt_size and max_rounds are arbitrarily chosen to provide
+ # a sanity check; the underlying pbkdf2 specifies no bounds for either.
+
+ #--GenericHandler--
+ name = "scram"
+ setting_kwds = ("salt", "salt_size", "rounds", "algs")
+ ident = u("$scram$")
+
+ #--HasSalt--
+ default_salt_size = 12
+ min_salt_size = 0
+ max_salt_size = 1024
+
+ #--HasRounds--
+ default_rounds = 6400
+ min_rounds = 1
+ max_rounds = 2**32-1
+ rounds_cost = "linear"
+
+ #--custom--
+
+ # default algorithms when creating new hashes.
+ default_algs = ["sha-1", "sha-256", "sha-512"]
+
+ # list of algs verify prefers to use, in order.
+ _verify_algs = ["sha-256", "sha-512", "sha-384", "sha-224", "sha-1"]
+
+ #=========================================================
+ # instance attrs
+ #=========================================================
+
+ # 'checksum' is different from most GenericHandler subclasses,
+ # in that it contains a dict mapping from alg -> digest,
+ # or None if no checksum present.
+
+ #: list of algorithms to create/compare digests for.
+ algs = None
+
+ #=========================================================
+ # scram frontend helpers
+ #=========================================================
+ @classmethod
+ def extract_digest_info(cls, hash, alg):
+ """given scram hash & hash alg, extracts salt, rounds and digest.
+
+ :arg hash:
+ Scram hash stored for desired user
+
+ :arg alg:
+ Name of digest algorithm (e.g. ``"sha-1"``) requested by client.
+
+ This value is run through :func:`norm_digest_name`,
+ so it is case-insensitive, and can be the raw SCRAM
+ mechanism name (e.g. ``"SCRAM-SHA-1"``), the IANA name,
+ or the hashlib name.
+
+ :raises KeyError:
+ If the hash does not contain an entry for the requested digest
+ algorithm.
+
+ :returns:
+ A tuple containing ``(salt, rounds, digest)``,
+ where *digest* matches the raw bytes return by
+ SCRAM's :func:`Hi` function for the stored password,
+ the provided *salt*, and the iteration count (*rounds*).
+ *salt* and *digest* are both raw (unencoded) bytes.
+ """
+ alg = norm_digest_name(alg)
+ self = cls.from_string(hash)
+ chkmap = self.checksum
+ if not chkmap:
+ raise ValueError("scram hash contains no digests")
+ return self.salt, self.rounds, chkmap[alg]
+
+ @classmethod
+ def extract_digest_algs(cls, hash, hashlib=False):
+ """Return names of all algorithms stored in a given hash.
+
+ :arg hash:
+ The scram hash to parse
+
+ :param hashlib:
+ By default this returns a list of IANA compatible names.
+ if this is set to `True`, hashlib-compatible names will
+ be returned instead.
+
+ :returns:
+ Returns a list of digest algorithms; e.g. ``["sha-1"]``,
+ or ``["sha1"]`` if ``hashlib=True``.
+ """
+ algs = cls.from_string(hash).algs
+ if hashlib:
+ return [iana_to_hashlib(alg) for alg in algs]
+ else:
+ return algs
+
+ @classmethod
+ def derive_digest(cls, password, salt, rounds, alg):
+ """helper to create SaltedPassword digest for SCRAM.
+
+ This performs the step in the SCRAM protocol described as::
+
+ SaltedPassword := Hi(Normalize(password), salt, i)
+
+ :arg password: password as unicode or utf-8 encoded bytes.
+ :arg salt: raw salt as bytes.
+ :arg rounds: number of iterations.
+ :arg alg: name of digest to use (e.g. ``"SHA-1"``).
+
+ :returns:
+ raw bytes of ``SaltedPassword``
+ """
+ if isinstance(password, bytes):
+ password = password.decode("utf-8")
+ password = saslprep(password).encode("utf-8")
+ if not isinstance(salt, bytes):
+ raise TypeError("salt must be bytes")
+ if rounds < 1:
+ raise ValueError("rounds must be >= 1")
+ alg = iana_to_hashlib(norm_digest_name(alg))
+ return pbkdf2(password, salt, rounds, -1, "hmac-" + alg)
+
+ #=========================================================
+ # serialization
+ #=========================================================
+
+ @classmethod
+ def from_string(cls, hash):
+ # parse hash
+ if not hash:
+ raise ValueError("no hash specified")
+ hash = to_native_str(hash, "ascii")
+ if not hash.startswith("$scram$"):
+ raise ValueError("invalid scram hash")
+ parts = hash[7:].split("$")
+ if len(parts) != 3:
+ raise ValueError("invalid scram hash")
+ rounds_str, salt_str, chk_str = parts
+
+ # decode rounds
+ rounds = int(rounds_str)
+ if rounds_str != str(rounds): #forbid zero padding, etc.
+ raise ValueError("invalid scram hash")
+
+ # decode salt
+ try:
+ salt = adapted_b64_decode(salt_str.encode("ascii"))
+ except TypeError:
+ raise ValueError("malformed scram hash")
+
+ # decode algs/digest list
+ if not chk_str:
+ # scram hashes MUST have something here.
+ raise ValueError("invalid scram hash")
+ elif "=" in chk_str:
+ # comma-separated list of 'alg=digest' pairs
+ algs = None
+ chkmap = {}
+ for pair in chk_str.split(","):
+ alg, digest = pair.split("=")
+ try:
+ chkmap[alg] = adapted_b64_decode(digest.encode("ascii"))
+ except TypeError:
+ raise ValueError("malformed scram hash")
+ else:
+ # comma-separated list of alg names, no digests
+ algs = chk_str
+ chkmap = None
+
+ # return new object
+ return cls(
+ rounds=rounds,
+ salt=salt,
+ checksum=chkmap,
+ algs=algs,
+ strict=chkmap is not None,
+ )
+
+ def to_string(self, withchk=True):
+ salt = adapted_b64_encode(self.salt)
+ if PY3:
+ salt = salt.decode("ascii")
+ chkmap = self.checksum
+ if withchk and chkmap:
+ chk_str = ",".join(
+ "%s=%s" % (alg, to_native_str(adapted_b64_encode(chkmap[alg])))
+ for alg in self.algs
+ )
+ else:
+ chk_str = ",".join(self.algs)
+ return '$scram$%d$%s$%s' % (self.rounds, salt, chk_str)
+
+ #=========================================================
+ # init
+ #=========================================================
+ def __init__(self, algs=None, **kwds):
+ super(scram, self).__init__(**kwds)
+ self.algs = self.norm_algs(algs)
+
+ @classmethod
+ def norm_checksum(cls, checksum, strict=False):
+ if checksum is None:
+ return None
+ for alg, digest in iteritems(checksum):
+ if alg != norm_digest_name(alg):
+ raise ValueError("malformed algorithm name in scram hash: %r" %
+ (alg,))
+ if len(alg) > 9:
+ raise ValueError("SCRAM limits algorithm names to "
+ "9 characters: %r" % (alg,))
+ if not isinstance(digest, bytes):
+ raise TypeError("digests must be raw bytes")
+ if 'sha-1' not in checksum:
+ # NOTE: required because of SCRAM spec.
+ raise ValueError("sha-1 must be in algorithm list of scram hash")
+ return checksum
+
+ def norm_algs(self, algs):
+ "normalize algs parameter"
+ # determine default algs value
+ if algs is None:
+ chk = self.checksum
+ if chk is None:
+ return list(self.default_algs)
+ else:
+ return sorted(chk)
+ elif self.checksum is not None:
+ raise RuntimeError("checksum & algs kwds are mutually exclusive")
+ # parse args value
+ if isinstance(algs, str):
+ algs = algs.split(",")
+ algs = sorted(norm_digest_name(alg) for alg in algs)
+ if any(len(alg)>9 for alg in algs):
+ raise ValueError("SCRAM limits alg names to max of 9 characters")
+ if 'sha-1' not in algs:
+ # NOTE: required because of SCRAM spec.
+ raise ValueError("sha-1 must be in algorithm list of scram hash")
+ return algs
+
+ #=========================================================
+ # digest methods
+ #=========================================================
+
+ @classmethod
+ def _deprecation_detector(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(**settings).algs)
+ def detector(hash):
+ return not algs.issubset(cls.from_string(hash).algs)
+ return detector
+
+ def calc_checksum(self, secret, alg=None):
+ rounds = self.rounds
+ salt = self.salt
+ hash = self.derive_digest
+ if alg:
+ # if requested, generate digest for specific alg
+ return hash(secret, salt, rounds, alg)
+ else:
+ # by default, return dict containing digests for all algs
+ return dict(
+ (alg, hash(secret, salt, rounds, alg))
+ for alg in self.algs
+ )
+
+ @classmethod
+ def verify(cls, secret, hash, full_verify=False):
+ self = cls.from_string(hash)
+ chkmap = self.checksum
+ if not chkmap:
+ raise ValueError("expected %s hash, got %s config string instead" %
+ (cls.name, cls.name))
+
+ # NOTE: to make the verify method efficient, we just calculate hash
+ # of shortest digest by default. apps can pass in "full_verify=True" to
+ # check entire hash for consistency.
+ if full_verify:
+ correct = failed = False
+ for alg, digest in iteritems(chkmap):
+ other = self.calc_checksum(secret, alg)
+ # NOTE: could do this length check in norm_algs(),
+ # but don't need to be that strict, and want to be able
+ # to parse hashes containing algs not supported by platform.
+ # it's fine if we fail here though.
+ if len(digest) != len(other):
+ raise ValueError("mis-sized %s digest in scram hash: %r != %r"
+ % (alg, len(digest), len(other)))
+ if consteq(other, digest):
+ correct = True
+ else:
+ failed = True
+ if correct and failed:
+ warning("scram hash verified inconsistently, may be corrupted")
+ return False
+ else:
+ return correct
+ else:
+ # otherwise only verify against one hash, pick one w/ best security.
+ for alg in self._verify_algs:
+ if alg in chkmap:
+ other = self.calc_checksum(secret, alg)
+ return consteq(other, chkmap[alg])
+ # there should *always* be at least sha-1.
+ raise AssertionError("sha-1 digest not found!")
+
+ #=========================================================
+ #
+ #=========================================================
+
+#=========================================================
+# code used for testing scram against protocol examples
+# during development, not part of public api or used internally.
+#=========================================================
+def _test_reference_scram():
+ "quick hack testing scram reference vectors"
+ # NOTE: "n,," is GS2 header - see https://tools.ietf.org/html/rfc5801
+
+ engine = _scram_engine(
+ alg="sha-1",
+ salt='QSXCR+Q6sek8bf92'.decode("base64"),
+ rounds=4096,
+ password=u"pencil",
+ )
+ print engine.digest.encode("base64").rstrip()
+
+ msg = engine.format_auth_msg(
+ username="user",
+ client_nonce = "fyko+d2lbbFgONRv9qkxdawL",
+ server_nonce = "3rfcNHYJY1ZVvWVs7j",
+ header='c=biws',
+ )
+
+ cp = engine.get_encoded_client_proof(msg)
+ assert cp == "v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=", cp
+
+ ss = engine.get_encoded_server_sig(msg)
+ assert ss == "rmF9pqV8S7suAoZWja4dJRkFsKQ=", ss
+
+class _scram_engine(object):
+ """helper class for verifying scram hash behavior
+ against SCRAM protocol examples. not officially part of Passlib.
+
+ takes in alg, salt, rounds, and a digest or password.
+
+ can calculate the various keys & messages of the scram protocol.
+
+ """
+ #=========================================================
+ # init
+ #=========================================================
+
+ @classmethod
+ def from_string(cls, hash, alg):
+ "create record from scram hash, for given alg"
+ return cls(alg, *scram.extract_digest_info(hash, alg))
+
+ def __init__(self, alg, salt, rounds, digest=None, password=None):
+ self.alg = norm_digest_name(alg)
+ self.salt = salt
+ self.rounds = rounds
+ self.password = password
+ if password:
+ data = scram.derive_digest(password, salt, rounds, alg)
+ if digest and data != digest:
+ raise ValueError("password doesn't match digest")
+ else:
+ digest = data
+ elif not digest:
+ raise TypeError("must provide password or digest")
+ self.digest = digest
+
+ #=========================================================
+ # frontend methods
+ #=========================================================
+ def get_hash(self, data):
+ "return hash of raw data"
+ return hashlib.new(iana_to_hashlib(self.alg), data).digest()
+
+ def get_client_proof(self, msg):
+ "return client proof of specified auth msg text"
+ return xor_bytes(self.client_key, self.get_client_sig(msg))
+
+ def get_encoded_client_proof(self, msg):
+ return self.get_client_proof(msg).encode("base64").rstrip()
+
+ def get_client_sig(self, msg):
+ "return client signature of specified auth msg text"
+ return self.get_hmac(self.stored_key, msg)
+
+ def get_server_sig(self, msg):
+ "return server signature of specified auth msg text"
+ return self.get_hmac(self.server_key, msg)
+
+ def get_encoded_server_sig(self, msg):
+ return self.get_server_sig(msg).encode("base64").rstrip()
+
+ def format_server_response(self, client_nonce, server_nonce):
+ return 'r={client_nonce}{server_nonce},s={salt},i={rounds}'.format(
+ client_nonce=client_nonce,
+ server_nonce=server_nonce,
+ rounds=self.rounds,
+ salt=self.encoded_salt,
+ )
+
+ def format_auth_msg(self, username, client_nonce, server_nonce,
+ header='c=biws'):
+ return (
+ 'n={username},r={client_nonce}'
+ ','
+ 'r={client_nonce}{server_nonce},s={salt},i={rounds}'
+ ','
+ '{header},r={client_nonce}{server_nonce}'
+ ).format(
+ username=username,
+ client_nonce=client_nonce,
+ server_nonce=server_nonce,
+ salt=self.encoded_salt,
+ rounds=self.rounds,
+ header=header,
+ )
+
+ #=========================================================
+ # helpers to calculate & cache constant data
+ #=========================================================
+ def _calc_get_hmac(self):
+ return get_prf("hmac-" + iana_to_hashlib(self.alg))[0]
+
+ def _calc_client_key(self):
+ return self.get_hmac(self.digest, b("Client Key"))
+
+ def _calc_stored_key(self):
+ return self.get_hash(self.client_key)
+
+ def _calc_server_key(self):
+ return self.get_hmac(self.digest, b("Server Key"))
+
+ def _calc_encoded_salt(self):
+ return self.salt.encode("base64").rstrip()
+
+ #=========================================================
+ # hacks for calculated attributes
+ #=========================================================
+
+ def __getattr__(self, attr):
+ if not attr.startswith("_"):
+ f = getattr(self, "_calc_" + attr, None)
+ if f:
+ value = f()
+ setattr(self, attr, value)
+ return value
+ raise AttributeError("attribute not found")
+
+ def __dir__(self):
+ cdir = dir(self.__class__)
+ attrs = set(cdir)
+ attrs.update(self.__dict__)
+ attrs.update(attr[6:] for attr in cdir
+ if attr.startswith("_calc_"))
+ return sorted(attrs)
+ #=========================================================
+ # eoc
+ #=========================================================
+
+#=========================================================
+#eof
+#=========================================================
diff --git a/passlib/registry.py b/passlib/registry.py
index 06a70ee..ba4b2a0 100644
--- a/passlib/registry.py
+++ b/passlib/registry.py
@@ -133,6 +133,7 @@ _handler_locations = {
"plaintext": ("passlib.handlers.misc", "plaintext"),
"postgres_md5": ("passlib.handlers.postgres", "postgres_md5"),
"roundup_plaintext":("passlib.handlers.roundup", "roundup_plaintext"),
+ "scram": ("passlib.handlers.scram", "scram"),
"sha1_crypt": ("passlib.handlers.sha1_crypt", "sha1_crypt"),
"sha256_crypt": ("passlib.handlers.sha2_crypt", "sha256_crypt"),
"sha512_crypt": ("passlib.handlers.sha2_crypt", "sha512_crypt"),
diff --git a/passlib/tests/test_handlers.py b/passlib/tests/test_handlers.py
index bbaeb71..5670339 100644
--- a/passlib/tests/test_handlers.py
+++ b/passlib/tests/test_handlers.py
@@ -1008,6 +1008,241 @@ class PostgresMD5CryptTest(HandlerCase):
return self.handler.genhash(secret, config, user=user)
#=========================================================
+# scram hash
+#=========================================================
+class ScramTest(HandlerCase):
+ handler = hash.scram
+
+ known_correct_hashes = [
+ # taken from example in SCRAM specification.
+ ('pencil', '$scram$4096$QSXCR.Q6sek8bf92$'
+ 'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30'),
+
+ # previous example, with sha-256 & sha-512 added.
+ ('pencil', '$scram$4096$QSXCR.Q6sek8bf92$'
+ 'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30,'
+ 'sha-256=qXUXrlcvnaxxWG00DdRgVioR2gnUpuX5r.3EZ1rdhVY,'
+ 'sha-512=lzgniLFcvglRLS0gt.C4gy.NurS3OIOVRAU1zZOV4P.qFiVFO2/'
+ 'edGQSu/kD1LwdX0SNV/KsPdHSwEl5qRTuZQ'),
+
+ #############
+ # TODO: need a bunch more reference vectors from some real
+ # SCRAM transactions.
+ #############
+
+ #############
+ # TODO: verify the following against some other SCRAM implementation.
+ #############
+
+ # the following hash should verify against both normalized
+ # and unnormalized versions of the password.
+ (u('\u2168\u3000a\u0300'), '$scram$6400$0BojBCBE6P2/N4bQ$'
+ 'sha-1=YniLes.b8WFMvBhtSACZyyvxeCc'),
+ (u('IX \xE0'), '$scram$6400$0BojBCBE6P2/N4bQ$'
+ 'sha-1=YniLes.b8WFMvBhtSACZyyvxeCc'),
+ (u('\u00ADIX \xE0'), '$scram$6400$0BojBCBE6P2/N4bQ$'
+ 'sha-1=YniLes.b8WFMvBhtSACZyyvxeCc'),
+ ]
+
+ known_malformed_hashes = [
+ # zero-padding in rounds
+ '$scram$04096$QSXCR.Q6sek8bf92$sha-1=HZbuOlKbWl.eR8AfIposuKbhX30',
+
+ # non-digit in rounds
+ '$scram$409A$QSXCR.Q6sek8bf92$sha-1=HZbuOlKbWl.eR8AfIposuKbhX30',
+
+ # bad char in salt
+ '$scram$4096$QSXCR.Q6sek8bf9-$sha-1=HZbuOlKbWl.eR8AfIposuKbhX30',
+
+ # bad char in digest
+ '$scram$4096$QSXCR.Q6sek8bf92$sha-1=HZbuOlKbWl.eR8AfIposuKbhX3-',
+
+ # too many chars in alg
+ '$scram$4096$QSXCR.Q6sek8bf92$shaxxx-190=HZbuOlKbWl.eR8AfIposuKbhX30',
+
+ # missing sha-1 alg
+ '$scram$4096$QSXCR.Q6sek8bf92$sha-256=HZbuOlKbWl.eR8AfIposuKbhX30',
+
+ ]
+
+ def test_100_algs(self):
+ "test parsing of 'algs' setting"
+ def parse(source):
+ return self.handler(algs=source).algs
+
+ # None -> default list
+ self.assertEquals(parse(None), ["sha-1","sha-256","sha-512"])
+
+ # strings should be parsed
+ self.assertEquals(parse("sha1"), ["sha-1"])
+ self.assertEquals(parse("sha1, sha256, md5"), ["md5","sha-1","sha-256"])
+
+ # lists should be normalized
+ self.assertEquals(parse(["sha-1","sha256"]), ["sha-1","sha-256"])
+
+ # sha-1 required
+ self.assertRaises(ValueError, parse, ["sha-256"])
+
+ # alg names < 10 chars
+ self.assertRaises(ValueError, parse, ["sha-1","shaxxx-890"])
+
+ # alg & checksum mutually exclusive.
+ self.assertRaises(RuntimeError, self.handler, algs=['sha-1'],
+ checksum={"sha-1": b("\x00"*20)})
+
+ def test_101_extract_digest_info(self):
+ "test scram.extract_digest_info()"
+ edi = self.handler.extract_digest_info
+
+ # 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,'\x01'))
+ self.assertEqual(edi(h,"bbb"), (s,10,'\x02'))
+ self.assertEqual(edi(h,"ccc"), (s,10,'\x03'))
+ self.assertRaises(KeyError, edi, h, "ddd")
+
+ # config strings should cause value error.
+ c = "$scram$10$....$sha-1,bbb,ccc"
+ self.assertRaises(ValueError, edi, c, "sha-1")
+ self.assertRaises(ValueError, edi, c, "bbb")
+ self.assertRaises(ValueError, edi, c, "ddd")
+
+ def test_102_extract_digest_algs(self):
+ "test scram.extract_digest_algs()"
+ eda = self.handler.extract_digest_algs
+
+ self.assertEquals(eda('$scram$4096$QSXCR.Q6sek8bf92$'
+ 'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30'), ["sha-1"])
+
+ self.assertEquals(eda('$scram$4096$QSXCR.Q6sek8bf92$'
+ 'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30,'
+ 'sha-256=qXUXrlcvnaxxWG00DdRgVioR2gnUpuX5r.3EZ1rdhVY,'
+ 'sha-512=lzgniLFcvglRLS0gt.C4gy.NurS3OIOVRAU1zZOV4P.qFiVFO2/'
+ 'edGQSu/kD1LwdX0SNV/KsPdHSwEl5qRTuZQ'),
+ ["sha-1","sha-256","sha-512"])
+
+ def test_103_derive_digest(self):
+ "test scram.derive_digest()"
+ # NOTE: this just does a light test, since derive_digest
+ # is used by encrypt / verify, and is tested pretty well via those.
+
+ 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')
+ 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(u("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'))
+ self.assertRaises(ValueError, hash, "IX", s1, 1000, 'sha-666')
+
+ # check rounds
+ self.assertRaises(ValueError, hash, "IX", s1, 0, 'sha-1')
+
+ def test_104_saslprep(self):
+ "test encrypt/verify use saslprep"
+ # NOTE: this just does a light test that saslprep() is being
+ # called in various places, relying in saslpreps()'s tests
+ # to verify full normalization behavior.
+
+ # encrypt unnormalized
+ h = self.do_encrypt(u("I\u00ADX"))
+ self.assertTrue(self.do_verify(u("IX"), h))
+ self.assertTrue(self.do_verify(u("\u2168"), h))
+
+ # encrypt normalized
+ h = self.do_encrypt(u("\xF3"))
+ self.assertTrue(self.do_verify(u("o\u0301"), h))
+ self.assertTrue(self.do_verify(u("\u200Do\u0301"), h))
+
+ # throws error if forbidden char provided
+ self.assertRaises(ValueError, self.do_encrypt, u("\uFDD0"))
+ self.assertRaises(ValueError, self.do_verify, u("\uFDD0"), h)
+
+ def test_105_context_algs(self):
+ "test handling of 'algs' in context object"
+ handler = self.handler
+ from passlib.context import CryptContext
+ c1 = CryptContext(["scram"], scram__algs="sha1,md5")
+
+ h = c1.encrypt("dummy")
+ self.assertEqual(handler.extract_digest_algs(h), ["md5", "sha-1"])
+ self.assertFalse(c1.hash_needs_update(h))
+
+ c2 = c1.replace(scram__algs="sha1")
+ self.assertFalse(c2.hash_needs_update(h))
+
+ c2 = c1.replace(scram__algs="sha1,sha256")
+ self.assertTrue(c2.hash_needs_update(h))
+
+ def test_106_full_verify(self):
+ "test full_verify flag"
+ def vfull(s, h):
+ return self.handler.verify(s, h, full_verify=True)
+
+ # reference
+ h = ('$scram$4096$QSXCR.Q6sek8bf92$'
+ 'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30,'
+ 'sha-256=qXUXrlcvnaxxWG00DdRgVioR2gnUpuX5r.3EZ1rdhVY,'
+ 'sha-512=lzgniLFcvglRLS0gt.C4gy.NurS3OIOVRAU1zZOV4P.qFiVFO2/'
+ 'edGQSu/kD1LwdX0SNV/KsPdHSwEl5qRTuZQ')
+ self.assertTrue(vfull('pencil', h))
+ self.assertFalse(vfull('tape', h))
+
+ # catch truncated digests.
+ h = ('$scram$4096$QSXCR.Q6sek8bf92$'
+ 'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30,'
+ 'sha-256=qXUXrlcvnaxxWG00DdRgVioR2gnUpuX5r.3EZ1rdhVY' # -1 char
+ 'sha-512=lzgniLFcvglRLS0gt.C4gy.NurS3OIOVRAU1zZOV4P.qFiVFO2/'
+ 'edGQSu/kD1LwdX0SNV/KsPdHSwEl5qRTuZQ')
+ self.assertRaises(ValueError, vfull, 'pencil', h)
+
+ # catch padded digests.
+ h = ('$scram$4096$QSXCR.Q6sek8bf92$'
+ 'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30,'
+ 'sha-256=qXUXrlcvnaxxWG00DdRgVioR2gnUpuX5r.3EZ1rdhVY,a' # +1 char
+ 'sha-512=lzgniLFcvglRLS0gt.C4gy.NurS3OIOVRAU1zZOV4P.qFiVFO2/'
+ 'edGQSu/kD1LwdX0SNV/KsPdHSwEl5qRTuZQ')
+ self.assertRaises(ValueError, vfull, 'pencil', h)
+
+ # catch digests belonging to diff passwords.
+ h = ('$scram$4096$QSXCR.Q6sek8bf92$'
+ 'sha-1=HZbuOlKbWl.eR8AfIposuKbhX30,'
+ 'sha-256=R7RJDWIbeKRTFwhE9oxh04kab0CllrQ3kCcpZUcligc' # 'tape'
+ 'sha-512=lzgniLFcvglRLS0gt.C4gy.NurS3OIOVRAU1zZOV4P.qFiVFO2/'
+ 'edGQSu/kD1LwdX0SNV/KsPdHSwEl5qRTuZQ')
+ self.assertRaises(ValueError, vfull, 'pencil', h)
+ self.assertRaises(ValueError, vfull, 'tape', h)
+
+ ndn_values = [
+ # normalized name, unnormalized names
+
+ # IANA assigned names
+ ("md5", "MD-5"),
+ ("sha-1", "SHA1"),
+ ("sha-256", "SHA_256", "sha2-256"),
+
+ # heuristic for unassigned names
+ ("abc6", "aBc-6"),
+ ("ripemd", "RIPEMD"),
+ ("ripemd-160", "RIPEmd160"),
+ ]
+
+ def test_107_norm_digest_name(self):
+ "test norm_digest_name helper"
+ from passlib.handlers.scram import norm_digest_name
+ for row in self.ndn_values:
+ result = row[0]
+ for value in row:
+ self.assertEqual(norm_digest_name(value), result)
+
+#=========================================================
# (netbsd's) sha1 crypt
#=========================================================
class _SHA1CryptTest(HandlerCase):
diff --git a/passlib/tests/test_utils.py b/passlib/tests/test_utils.py
index ef4ecd3..5396a89 100644
--- a/passlib/tests/test_utils.py
+++ b/passlib/tests/test_utils.py
@@ -849,7 +849,8 @@ class _Pbkdf2BackendTest(TestCase):
self.assertRaises(TypeError, pbkdf2.pbkdf2, b('password'), b('salt'), 'x', 16)
#invalid keylen
- self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'), 1, 20*(2**32-1)+1)
+ self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'),
+ 1, 20*(2**32-1)+1)
#invalid salt type
self.assertRaises(TypeError, pbkdf2.pbkdf2, b('password'), 5, 1, 10)
@@ -862,6 +863,14 @@ class _Pbkdf2BackendTest(TestCase):
self.assertRaises(ValueError, pbkdf2.pbkdf2, b('password'), b('salt'), 1, 16, 'foo')
self.assertRaises(TypeError, pbkdf2.pbkdf2, b('password'), b('salt'), 1, 16, 5)
+ def test_default_keylen(self):
+ "test keylen==-1"
+ self.assertEqual(len(pbkdf2.pbkdf2(b('password'), b('salt'), 1, -1,
+ prf='hmac-sha1')), 20)
+
+ self.assertEqual(len(pbkdf2.pbkdf2(b('password'), b('salt'), 1, -1,
+ prf='hmac-sha256')), 32)
+
def test_hmac_sha1(self):
"test independant hmac_sha1() method"
self.assertEqual(
diff --git a/passlib/utils/compat.py b/passlib/utils/compat.py
index cb6925d..bd39a5b 100644
--- a/passlib/utils/compat.py
+++ b/passlib/utils/compat.py
@@ -3,6 +3,7 @@
# figure out what version we're running
#=============================================================================
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
@@ -171,9 +172,13 @@ else:
if PY3:
def iteritems(d):
return d.items()
+ def itervalues(d):
+ return d.values()
else:
def iteritems(d):
return d.iteritems()
+ def itervalues(d):
+ return d.itervalues()
#=============================================================================
# introspection
diff --git a/passlib/utils/handlers.py b/passlib/utils/handlers.py
index d4083bf..26d0708 100644
--- a/passlib/utils/handlers.py
+++ b/passlib/utils/handlers.py
@@ -337,11 +337,13 @@ class GenericHandler(object):
#instance attrs
#=====================================================
checksum = None
+ strict = False #: whether norm_xxx() functions should use strict checking.
#=====================================================
#init
#=====================================================
def __init__(self, checksum=None, strict=False, **kwds):
+ self.strict = strict
self.checksum = self.norm_checksum(checksum, strict=strict)
super(GenericHandler, self).__init__(**kwds)
diff --git a/passlib/utils/pbkdf2.py b/passlib/utils/pbkdf2.py
index 27ccf4c..c48f751 100644
--- a/passlib/utils/pbkdf2.py
+++ b/passlib/utils/pbkdf2.py
@@ -156,7 +156,7 @@ def pbkdf1(secret, salt, rounds, keylen, hash="sha1"):
:arg secret: passphrase to use to generate key
:arg salt: salt string to use when generating key
:param rounds: number of rounds to use to generate key
- :arg keylen: number of bytes to generate
+ :arg keylen: number of bytes to generate.
:param hash:
hash function to use.
if specified, it must be one of the following:
@@ -225,7 +225,9 @@ def pbkdf2(secret, salt, rounds, keylen, prf="hmac-sha1"):
:arg secret: passphrase to use to generate key
:arg salt: salt string to use when generating key
:param rounds: number of rounds to use to generate key
- :arg keylen: number of bytes to generate
+ :arg keylen:
+ number of bytes to generate.
+ if -1, will use digest size of prf.
:param prf:
psuedo-random family to use for key strengthening.
this can be any string or callable accepted by :func:`get_prf`.
@@ -249,6 +251,8 @@ def pbkdf2(secret, salt, rounds, keylen, prf="hmac-sha1"):
#special case for m2crypto + hmac-sha1
if prf == "hmac-sha1" and _EVP:
+ if keylen == -1:
+ keylen = 20
#NOTE: doing check here, because M2crypto won't take longs (which this is, under 32bit)
if keylen > MAX_HMAC_SHA1_KEYLEN:
raise ValueError("key length too long")
@@ -261,6 +265,8 @@ def pbkdf2(secret, salt, rounds, keylen, prf="hmac-sha1"):
#resolve prf
encode_block, digest_size = get_prf(prf)
+ if keylen == -1:
+ keylen = digest_size
#figure out how many blocks we'll need
bcount = (keylen+digest_size-1)//digest_size