diff options
| author | Eli Collins <elic@assurancetechnologies.com> | 2012-01-09 23:17:30 -0500 |
|---|---|---|
| committer | Eli Collins <elic@assurancetechnologies.com> | 2012-01-09 23:17:30 -0500 |
| commit | 1c449d2ddea632f3b7770f6d0c08f8435ea0cd18 (patch) | |
| tree | f2d57863635c81c7267340eaf35c684ce6154420 /passlib | |
| parent | 29e6db01cb272996a3e6f88cdbd8662f7024d605 (diff) | |
| download | passlib-1c449d2ddea632f3b7770f6d0c08f8435ea0cd18.tar.gz | |
lots of work on scram hash
handler
-------
* added 'scram' to default registry list
* handler 'algs' keyword now parsed & validated correctly
* digest names normalized -> IANA spec
* saslprep() integrated into code
* added config string format
related
-------
* added documentation (still needs cleaning up though)
* added majority of UTs, still need to add a few edge cases
other
-----
* redid context->handler deprecation link - code now looks for
handler._deprecated_detector(settings) to generate a callable,
should be more efficient, and allow errors to be throw at bind-time
instead of call-time.
* pbkdf2() function now treats keylen = -1 as request for
keylen = PRF digest size.
Diffstat (limited to 'passlib')
| -rw-r--r-- | passlib/context.py | 22 | ||||
| -rw-r--r-- | passlib/handlers/bcrypt.py | 4 | ||||
| -rw-r--r-- | passlib/handlers/scram.py | 574 | ||||
| -rw-r--r-- | passlib/registry.py | 1 | ||||
| -rw-r--r-- | passlib/tests/test_handlers.py | 218 | ||||
| -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 |
9 files changed, 724 insertions, 123 deletions
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 index 3ffcc0e..b5daf39 100644 --- a/passlib/handlers/scram.py +++ b/passlib/handlers/scram.py @@ -17,14 +17,16 @@ scram protocol - http://tools.ietf.org/html/rfc5802 #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, \ - handlers as uh, to_native_str, to_unicode, bytes, b, consteq -from passlib.utils.compat import unicode, bytes, u, b +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 @@ -49,7 +51,7 @@ def test_reference_scram(): server_nonce = "3rfcNHYJY1ZVvWVs7j" # hash passwd - hk = pbkdf2(password, salt, rounds, 20, prf="hmac-" + digest) + hk = pbkdf2(password, salt, rounds, -1, prf="hmac-" + digest) # auth msg auth_msg = ( @@ -61,6 +63,7 @@ def test_reference_scram(): ).format(salt=salt.encode("base64").rstrip(), rounds=rounds, client_nonce=client_nonce, server_nonce=server_nonce, username=username) + print repr(auth_msg) # client proof hmac, hmac_size = get_prf("hmac-" + digest) @@ -74,32 +77,252 @@ def test_reference_scram(): ss = hmac(sk, auth_msg).encode("base64").rstrip() assert ss == "rmF9pqV8S7suAoZWja4dJRkFsKQ=", ss -# TODO: norm_digest_name(), stringprep() +class scram_record(tuple): + #========================================================= + # 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 __new__(cls, *args): + return tuple.__new__(cls, args) + + def __init__(self, salt, rounds, alg, digest): + self.alg = norm_digest_name(alg) + self.salt = salt + self.rounds = rounds + 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_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 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=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 + #========================================================= + +#========================================================= +# 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): - "base class for various pbkdf2_{digest} algorithms" - ## __doc__="""This class implements a generic ``PBKDF2-%(prf)s``-based password hash, 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 %(dsc)d byte salt will be autogenerated (this is recommended). - ## - ##:param salt_size: - ## Optional number of bytes to use when autogenerating new salts. - ## Defaults to 16 bytes, but can be any value between 0 and 1024. - ## - ##:param rounds: - ## Optional number of rounds to use. - ## Defaults to %(dr)d, but must be within ``range(1,1<<32)``. + """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 may 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 hashlib or IANA compatible hash names. + + This class also provides the following additional class 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 #========================================================= @@ -108,15 +331,12 @@ class scram(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler): # ScramHandler is actually a map from digest_name -> digest, so # many of the standard methods have been overridden. - # XXX: how to name digest list? "digest_names", "digests" ? - # 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", "digest_names") - checksum_chars = uh.H64_CHARS + setting_kwds = ("salt", "salt_size", "rounds", "algs") ident = u("$scram$") #--HasSalt-- @@ -130,156 +350,284 @@ class scram(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler): 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-specific class methods + # scram frontend helpers #========================================================= @classmethod - def extract_digest_info(cls, hash, digest_name): - """given scram hash & negotiated digest, returns ``(salt,rounds,digest)``. + 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 hash: scram-format hash stored for desired user - :arg digest_name: name of digest (eg ``SHA-1``) requested by client. + :arg alg: + Name of digest algorithm (e.g. ``"sha-1"``) requested by client. - :raises LookupError: - If the hash does not contain a key derived from the password - using the requested digest. + 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)``; - corresponding to (respectively) the *salt*, *iteration count*, - and *SaltedPassword* required by the SCRAM protocol. + 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) - digest_name = normalize_digest_name(digest_name) - return self.checksums[digest_name] + chkmap = self.checksum + if not chkmap: + raise ValueError("scram hash contains no digests") + return self.salt, self.rounds, chkmap[alg] @classmethod - def extract_digest_names(cls, hash): - """given scran hash, return names of all digests that have - been calculated for the stored password (e.g. ``["SHA-1"]``) + 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``. """ - self = cls.from_string(hash) - return self.digest_names + 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, digest_name): + 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: unicode password - :arg salt: raw salt bytes, or base64 encoded salt - :arg rounds: number of iteration - :arg digest_name: SCRAM-compatible name of digest (e.g. ``SHA-1``). + :arg password: password as unicode or utf-8 encoded bytes. + :arg salt: raw salt as bytes. + :arg rounds: number of iterations. + :arg alg: SCRAM-compatible name of digest (e.g. ``"SHA-1"``). :returns: raw bytes of SaltedPassword """ - password = stringprep(password).encode("utf-8") - if isinstance(salt, unicode): - salt = salt.decode("base64") - elif not isinstance(salt, bytes): - raise TypeError("salt must be base64-unicode or bytes") - return pbkdf2(password, salt, rounds, "HMAC-" + digest_name) + 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") + alg = iana_to_hashlib(norm_digest_name(alg)) + return pbkdf2(password, salt, rounds, -1, "hmac-" + alg) #========================================================= - #methods + # serialization #========================================================= - # TODO: add deprecate hooks allowing auto-upgrade - # when digest_names is changed in context. - - # TODO: add 'digests' keyword to let encrypt() specific - # which digests should be supported. - - # scram hash format: - # $scram$<rounds>$<salt>$<digest-name>=<digest-contents>,... - @classmethod def from_string(cls, hash): + # parse hash if not hash: raise ValueError("no hash specified") - rounds, salt, chk = uh.parse_mc3(hash, cls.ident, cls.name) - int_rounds = int(rounds) - if rounds != unicode(int_rounds): #forbid zero padding, etc. - raise ValueError("invalid %s hash" % (cls.name,)) - raw_salt = adapted_b64_decode(salt.encode("ascii")) - if chk: + 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 + salt = adapted_b64_decode(salt_str.encode("ascii")) + + # 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 part in chk.rstrip(u(",")).split(u(",")): - name, digest = part.split(u("=")) - name = normalize_digest_name(name) - chkmap[name] = adapted_b64_decode(digest) + for pair in chk_str.split(","): + alg, digest = pair.split("=") + chkmap[alg] = adapted_b64_decode(digest.encode("ascii")) else: + # comma-separated list of alg names, no digests + algs = chk_str chkmap = None + + # return new object return cls( - rounds=int_rounds, - salt=raw_salt, + rounds=rounds, + salt=salt, checksum=chkmap, - strict=bool(chk), + algs=algs, + strict=chkmap is not None, ) def to_string(self, withchk=True): - salt = adapted_b64_encode(self.salt).decode("ascii") - if withchk and self.checksum: - chkmap = self.checksum - chk = u(',').join( - u("%s=%s") % (name.lower(), adapted_b64_encode(chkmap[name])) - for name in sorted(chkmap) + 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 ) - hash = u('%s%d$%s$%s') % (self.ident, self.rounds, salt, chk) else: - hash = u('%s%d$%s') % (self.ident, self.rounds, salt) - return to_native_str(hash) - - def calc_checksum(self, secret): - rounds = self.rounds - salt = self.salt - func = self.derive_digest - return dict( - (name, func(secret, salt, rounds, name)) - for name in self.digest_names - ) + chk_str = ",".join(self.algs) + return '$scram$%d$%s$%s' % (self.rounds, salt, chk_str) #========================================================= - # overridden standard methods + # 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): - # TODO: wrap orig norm_checksum to handle checksum being a dict. - raise NotImplementedError + 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=False): + def verify(cls, secret, hash, full_verify=False): self = cls.from_string(hash) chkmap = self.checksum if not chkmap: return False # NOTE: to make the verify method efficient, we just calculate hash - # of shortest digest by default. apps can pass in "full=True" to + # of shortest digest by default. apps can pass in "full_verify=True" to # check entire hash for consistency. - if full: - other = self.calc_checksum(secret) + if full_verify: correct = failed = False - for name, value in chkmap.iteritems(): - if consteq(other[name], value): + 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 compared inconsistently, may be corrupted") + warning("scram hash verified inconsistently, may be corrupted") return False else: return correct else: - def sk(item): - return len(item[1]), item[0] - name, value = min(chkmap.iteritems(), key=sk) - other = self.derive_digest(secret, self.salt, self.rounds, name) - return consteq(other, value) + # 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!") #========================================================= # 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..81e3958 100644 --- a/passlib/tests/test_handlers.py +++ b/passlib/tests/test_handlers.py @@ -1008,6 +1008,224 @@ 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', + +# FIXME: bad chars raise TypeError + # 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"]) + + # TODO. + def test_103_derive_digest(self): + "test scram.derive_digest()" + pass + + 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 |
