summaryrefslogtreecommitdiff
path: root/passlib
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2012-01-09 23:17:30 -0500
committerEli Collins <elic@assurancetechnologies.com>2012-01-09 23:17:30 -0500
commit1c449d2ddea632f3b7770f6d0c08f8435ea0cd18 (patch)
treef2d57863635c81c7267340eaf35c684ce6154420 /passlib
parent29e6db01cb272996a3e6f88cdbd8662f7024d605 (diff)
downloadpasslib-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.py22
-rw-r--r--passlib/handlers/bcrypt.py4
-rw-r--r--passlib/handlers/scram.py574
-rw-r--r--passlib/registry.py1
-rw-r--r--passlib/tests/test_handlers.py218
-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
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