diff options
| author | Eli Collins <elic@assurancetechnologies.com> | 2012-01-10 00:23:06 -0500 |
|---|---|---|
| committer | Eli Collins <elic@assurancetechnologies.com> | 2012-01-10 00:23:06 -0500 |
| commit | 7b64a499169c533ab97501ccb9eeed5d27c6e47c (patch) | |
| tree | 9b22cc0b60d9d91d3da6ed513eb16e7304694121 | |
| parent | c42fd113ad3da503415c70008d945e24deb3536a (diff) | |
| parent | 14c2d65502c576043e8c493a0b9b2265d3d501b0 (diff) | |
| download | passlib-7b64a499169c533ab97501ccb9eeed5d27c6e47c.tar.gz | |
Merge scram-dev: adds hash for storing SCRAM protocol digests
| -rw-r--r-- | CHANGES | 25 | ||||
| -rw-r--r-- | docs/lib/passlib.hash.rst | 1 | ||||
| -rw-r--r-- | docs/lib/passlib.hash.scram.rst | 143 | ||||
| -rw-r--r-- | docs/modular_crypt_format.rst | 10 | ||||
| -rw-r--r-- | passlib/context.py | 22 | ||||
| -rw-r--r-- | passlib/handlers/bcrypt.py | 4 | ||||
| -rw-r--r-- | passlib/handlers/scram.py | 655 | ||||
| -rw-r--r-- | passlib/registry.py | 1 | ||||
| -rw-r--r-- | passlib/tests/test_handlers.py | 235 | ||||
| -rw-r--r-- | passlib/tests/test_utils.py | 11 | ||||
| -rw-r--r-- | passlib/utils/compat.py | 5 | ||||
| -rw-r--r-- | passlib/utils/handlers.py | 2 | ||||
| -rw-r--r-- | passlib/utils/pbkdf2.py | 10 |
13 files changed, 1102 insertions, 22 deletions
@@ -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 |
