diff options
author | Eli Collins <elic@assurancetechnologies.com> | 2012-03-09 18:45:34 -0500 |
---|---|---|
committer | Eli Collins <elic@assurancetechnologies.com> | 2012-03-09 18:45:34 -0500 |
commit | 72ec6bedc4fdad8845b39788430f32345234ca67 (patch) | |
tree | cfd206065093a58646f6b4bef9415804b6c0d96a | |
parent | e0803178ae49f1fbaa367a7564b9877eacce628e (diff) | |
download | passlib-72ec6bedc4fdad8845b39788430f32345234ca67.tar.gz |
utils.handlers framework reworked; removed a bunch of boilerplate code
* StaticHandler is now subclass of GenericHandler
- _calc_checksum() should be implemented instead of encrypt().
(compatibility stub added so old code should continue to work)
- _norm_hash() no longer needs to handle ->unicode conversion
- default from_string() contains a bunch of features,
including stripping a known prefix, etc.
* context kwds now pulled into constructor, so GenericHandler
supports context kwds properly; HasUserContext mixin added
to support common 'user' context kwd
* identify_regexp & identify_prefix removed, functionality
rolled into default GenericHandler.identify() implementation.
- default identify checks _hash_regex as potential way to identify hashes
* HasStubChecksum removed, functionality rolled into GenericHandler
* HasRawChecksum now just sets a flag, functionality moved into GenericHandler
* HasManyIdents._parse_ident() helper added to valid & split identifier
from hashes.
* GenericHandler._norm_checksum() is now strict about unicode / bytes
-rw-r--r-- | CHANGES | 11 | ||||
-rw-r--r-- | docs/lib/passlib.utils.handlers.rst | 11 | ||||
-rw-r--r-- | passlib/handlers/bcrypt.py | 16 | ||||
-rw-r--r-- | passlib/handlers/des_crypt.py | 43 | ||||
-rw-r--r-- | passlib/handlers/digests.py | 44 | ||||
-rw-r--r-- | passlib/handlers/django.py | 26 | ||||
-rw-r--r-- | passlib/handlers/fshp.py | 18 | ||||
-rw-r--r-- | passlib/handlers/ldap_digests.py | 66 | ||||
-rw-r--r-- | passlib/handlers/misc.py | 65 | ||||
-rw-r--r-- | passlib/handlers/mysql.py | 68 | ||||
-rw-r--r-- | passlib/handlers/nthash.py | 13 | ||||
-rw-r--r-- | passlib/handlers/oracle.py | 65 | ||||
-rw-r--r-- | passlib/handlers/pbkdf2.py | 6 | ||||
-rw-r--r-- | passlib/handlers/phpass.py | 11 | ||||
-rw-r--r-- | passlib/handlers/postgres.py | 39 | ||||
-rw-r--r-- | passlib/handlers/sha2_crypt.py | 12 | ||||
-rw-r--r-- | passlib/handlers/sun_md5_crypt.py | 11 | ||||
-rw-r--r-- | passlib/tests/test_context.py | 5 | ||||
-rw-r--r-- | passlib/tests/test_utils_handlers.py | 118 | ||||
-rw-r--r-- | passlib/utils/handlers.py | 622 |
20 files changed, 627 insertions, 643 deletions
@@ -46,6 +46,10 @@ Release History .. currentmodule:: passlib.utils.handlers + * Internal handler framework (:mod:`passlib.utils.handlers`) rewritten + drastically. Provides stricter input checking, reduction in + boilerplate code. + * :class:`~passlib.utils.handlers.GenericHandler` and related mixins changed in backward-incompatible way: the ``strict`` keyword was removed. :class:`!GenericHandler` now defaults to a behavior @@ -54,6 +58,10 @@ Release History The new keywords ``use_defaults`` and ``relaxed`` can be used to disable these two requirements, respectively. + * :class:`~passlib.utils.handlers.StaticHandler` now derived from + :class:`!GenericHandler`, and required ``_calc_checksum()`` be + implemented instead of ``encrypt()``. + * :class:`~passlib.utils.handlers.GenericHandler` and related mixins changed in backward-incompatible way: the :samp:`norm_{xxx}` classmethods have been renamed to :samp:`_norm_{xxx}`, and turned @@ -85,6 +93,9 @@ Release History * Passlib is now source-compatible with Python 2.5+ and Python 3, and no longer requires the use of :command:`2to3` to run under Python 3. + * Hash unittest framework rewritten. More border cases handled, + some simple fuzz testing added. + .. currentmodule:: passlib.hash .. _consteq-issue: diff --git a/docs/lib/passlib.utils.handlers.rst b/docs/lib/passlib.utils.handlers.rst index 325c730..8d0e202 100644 --- a/docs/lib/passlib.utils.handlers.rst +++ b/docs/lib/passlib.utils.handlers.rst @@ -8,17 +8,18 @@ .. module:: passlib.utils.handlers :synopsis: helper classes for writing password hash handlers +.. warning:: + + This module is primarily used as an internal support module. + It's interface has not been finalized yet, and may change between major + releases of Passlib. + .. todo:: This module, and the instructions on how to write a custom handler, definitely need to be rewritten for clarity. They are not yet organized, and may leave out some important details. -.. note:: - - Since this module is primarily a support module used internally - by Passlib, it's interface may change slightly between major releases. - Implementing Custom Handlers ============================ All that is required in order to write a custom handler that will work with diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py index 5f89b66..0ca17f5 100644 --- a/passlib/handlers/bcrypt.py +++ b/passlib/handlers/bcrypt.py @@ -122,16 +122,8 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. @classmethod def from_string(cls, hash): - if not hash: - raise ValueError("no hash specified") - if isinstance(hash, bytes): - hash = hash.decode("ascii") - for ident in cls.ident_values: - if hash.startswith(ident): - break - else: - raise ValueError("invalid bcrypt hash") - rounds, data = hash[len(ident):].split(u("$")) + ident, tail = cls._parse_ident(hash) + rounds, data = tail.split(u("$")) rval = int(rounds) if rounds != u('%02d') % (rval,): raise ValueError("invalid bcrypt hash (rounds not zero-padded)") @@ -259,6 +251,10 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. # py2: unicode secret/hash encoded as ascii bytes before use, # bytes takes as-is; returns ascii bytes. # py3: can't get to install + + # FIXME: bcryptor doesn't support v0 hashes ("$2$"), + # will throw bcryptor.engine.SaltError at this point. + if isinstance(secret, unicode): secret = secret.encode("utf-8") hash = bcryptor_engine(False).hash_key(secret, self.to_string()) diff --git a/passlib/handlers/des_crypt.py b/passlib/handlers/des_crypt.py index 6b3c436..3eea448 100644 --- a/passlib/handlers/des_crypt.py +++ b/passlib/handlers/des_crypt.py @@ -174,17 +174,13 @@ class des_crypt(uh.HasManyBackends, uh.HasSalt, uh.GenericHandler): #========================================================= #FORMAT: 2 chars of H64-encoded salt + 11 chars of H64-encoded checksum - _pat = re.compile(u(r""" + _hash_regex = re.compile(u(r""" ^ (?P<salt>[./a-z0-9]{2}) (?P<chk>[./a-z0-9]{11})? $"""), re.X|re.I) @classmethod - def identify(cls, hash): - return uh.identify_regexp(hash, cls._pat) - - @classmethod def from_string(cls, hash): if not hash: raise ValueError("no hash specified") @@ -235,10 +231,6 @@ class des_crypt(uh.HasManyBackends, uh.HasSalt, uh.GenericHandler): #handler #========================================================= -#FIXME: phpass code notes that even rounds values should be avoided for BSDI-Crypt, -# so as not to reveal weak des keys. given the random salt, this shouldn't be -# a very likely issue anyways, but should do something about default rounds generation anyways. - class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler): """This class implements the BSDi-Crypt password hash, and follows the :ref:`password-hash-api`. @@ -287,7 +279,7 @@ class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler #========================================================= #internal helpers #========================================================= - _pat = re.compile(u(r""" + _hash_regex = re.compile(u(r""" ^ _ (?P<rounds>[./a-z0-9]{4}) @@ -296,16 +288,12 @@ class bsdi_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler $"""), re.X|re.I) @classmethod - def identify(cls, hash): - return uh.identify_regexp(hash, cls._pat) - - @classmethod def from_string(cls, hash): if not hash: raise ValueError("no hash specified") if isinstance(hash, bytes): hash = hash.decode("ascii") - m = cls._pat.match(hash) + m = cls._hash_regex.match(hash) if not m: raise ValueError("invalid ext-des-crypt hash") rounds, salt, chk = m.group("rounds", "salt", "chk") @@ -378,30 +366,19 @@ class bigcrypt(uh.HasSalt, uh.GenericHandler): #========================================================= #internal helpers #========================================================= - _pat = re.compile(u(r""" + _hash_regex = re.compile(u(r""" ^ (?P<salt>[./a-z0-9]{2}) - (?P<chk>[./a-z0-9]{11,})? + (?P<chk>([./a-z0-9]{11})+)? $"""), re.X|re.I) @classmethod - def identify(cls, hash): - if not hash: - return False - if isinstance(hash, bytes): - try: - hash = hash.decode("ascii") - except UnicodeDecodeError: - return False - return bool(cls._pat.match(hash)) and (len(hash)-2) % 11 == 0 - - @classmethod def from_string(cls, hash): if not hash: raise ValueError("no hash specified") if isinstance(hash, bytes): hash = hash.decode("ascii") - m = cls._pat.match(hash) + m = cls._hash_regex.match(hash) if not m: raise ValueError("invalid bigcrypt hash") salt, chk = m.group("salt", "chk") @@ -469,23 +446,19 @@ class crypt16(uh.HasSalt, uh.GenericHandler): #========================================================= #internal helpers #========================================================= - _pat = re.compile(u(r""" + _hash_regex = re.compile(u(r""" ^ (?P<salt>[./a-z0-9]{2}) (?P<chk>[./a-z0-9]{22})? $"""), re.X|re.I) @classmethod - def identify(cls, hash): - return uh.identify_regexp(hash, cls._pat) - - @classmethod def from_string(cls, hash): if not hash: raise ValueError("no hash specified") if isinstance(hash, bytes): hash = hash.decode("ascii") - m = cls._pat.match(hash) + m = cls._hash_regex.match(hash) if not m: raise ValueError("invalid crypt16 hash") salt, chk = m.group("salt", "chk") diff --git a/passlib/handlers/digests.py b/passlib/handlers/digests.py index 80b4371..03db62c 100644 --- a/passlib/handlers/digests.py +++ b/passlib/handlers/digests.py @@ -9,8 +9,8 @@ import logging; log = logging.getLogger(__name__) from warnings import warn #site #libs -from passlib.utils import to_native_str -from passlib.utils.compat import bascii_to_str, bytes, unicode +from passlib.utils import to_native_str, to_bytes +from passlib.utils.compat import bascii_to_str, bytes, unicode, str_to_uascii import passlib.utils.handlers as uh from passlib.utils.md4 import md4 #pkg @@ -29,35 +29,27 @@ __all__ = [ #========================================================= class HexDigestHash(uh.StaticHandler): "this provides a template for supporting passwords stored as plain hexidecimal hashes" - _hash_func = None #required - hash function - checksum_size = None #required - size of encoded digest + #========================================================= + # class attrs + #========================================================= + _hash_func = None # hash function to use - filled in by create_hex_hash() + checksum_size = None # filled in by create_hex_hash() checksum_chars = uh.HEX_CHARS + #========================================================= + # methods + #========================================================= @classmethod - def identify(cls, hash): - if not hash: - return False - if isinstance(hash, bytes): - try: - hash = hash.decode("ascii") - except UnicodeDecodeError: - return False - cc = cls.checksum_chars - return len(hash) == cls.checksum_size and all(c in cc for c in hash) + def _norm_hash(cls, hash): + return hash.lower() - @classmethod - def genhash(cls, secret, hash): - if hash is not None and not cls.identify(hash): - raise ValueError("not a %s hash" % (cls.name,)) - if secret is None: - raise TypeError("no secret provided") - if isinstance(secret, unicode): - secret = secret.encode("utf-8") - return cls._hash_func(secret).hexdigest() + def _calc_checksum(self, secret): + secret = to_bytes(secret, "utf-8", errname="secret") + return str_to_uascii(self._hash_func(secret).hexdigest()) - @classmethod - def _norm_hash(cls, hash): - return to_native_str(hash, "ascii", errname="hash").lower() + #========================================================= + # eoc + #========================================================= def create_hex_hash(hash, digest_name): #NOTE: could set digest_name=hash.name for cpython, but not for some other platforms. diff --git a/passlib/handlers/django.py b/passlib/handlers/django.py index 901a0a0..b91c265 100644 --- a/passlib/handlers/django.py +++ b/passlib/handlers/django.py @@ -35,7 +35,7 @@ def _import_des_crypt(): #========================================================= #salted hashes #========================================================= -class DjangoSaltedHash(uh.HasStubChecksum, uh.HasSalt, uh.GenericHandler): +class DjangoSaltedHash(uh.HasSalt, uh.GenericHandler): """base class providing common code for django hashes""" #must be specified by subclass - along w/ calc_checksum setting_kwds = ("salt", "salt_size") @@ -48,10 +48,6 @@ class DjangoSaltedHash(uh.HasStubChecksum, uh.HasSalt, uh.GenericHandler): salt_chars = checksum_chars = uh.LOWER_HEX_CHARS @classmethod - def identify(cls, hash): - return uh.identify_prefix(hash, cls.ident) - - @classmethod def from_string(cls, hash): if not hash: raise ValueError("no hash specified") @@ -144,7 +140,7 @@ class django_des_crypt(DjangoSaltedHash): """ name = "django_des_crypt" - ident = "crypt$" + ident = u("crypt$") checksum_chars = salt_chars = uh.HASH64_CHARS checksum_size = 13 min_salt_size = 2 @@ -162,9 +158,14 @@ class django_des_crypt(DjangoSaltedHash): # else hash can *never* validate salt = self.salt chk = self.checksum - if salt and chk and salt[:2] != chk[:2]: - raise ValueError("invalid django_des_crypt hash: " - "first two digits of salt and checksum must match") + if salt and chk: + if salt[:2] != chk[:2]: + raise ValueError("invalid django_des_crypt hash: " + "first two digits of salt and checksum must match") + # repeat stub checksum detection since salt isn't set + # when _norm_checksum() is called. + if chk == self._stub_checksum: + self.checksum = None _base_stub_checksum = u('.') * 13 @@ -207,14 +208,15 @@ class django_disabled(uh.StaticHandler): else: return hash == u("!") - @classmethod - def genhash(cls, secret, config): + def _calc_checksum(self, secret): if secret is None: raise TypeError("no secret provided") - return "!" + return u("!") @classmethod def verify(cls, secret, hash): + if secret is None: + raise TypeError("no secret provided") if not cls.identify(hash): raise ValueError("invalid django-disabled hash") return False diff --git a/passlib/handlers/fshp.py b/passlib/handlers/fshp.py index 6141d4e..f24805d 100644 --- a/passlib/handlers/fshp.py +++ b/passlib/handlers/fshp.py @@ -23,7 +23,7 @@ __all__ = [ #========================================================= #sha1-crypt #========================================================= -class fshp(uh.HasStubChecksum, uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler): +class fshp(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler): """This class implements the FSHP password hash, and follows the :ref:`password-hash-api`. It supports a variable-length salt, and a variable number of rounds. @@ -58,6 +58,7 @@ class fshp(uh.HasStubChecksum, uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, u name = "fshp" setting_kwds = ("salt", "salt_size", "rounds", "variant") checksum_chars = uh.PADDED_BASE64_CHARS + ident = u("{FSHP") # checksum_size is property() that depends on variant #--HasRawSalt-- @@ -129,11 +130,14 @@ class fshp(uh.HasStubChecksum, uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, u #formatting #========================================================= - @classmethod - def identify(cls, hash): - return uh.identify_prefix(hash, u("{FSHP")) - - _fshp_re = re.compile(u(r"^\{FSHP(\d+)\|(\d+)\|(\d+)\}([a-zA-Z0-9+/]+={0,3})$")) + _hash_regex = re.compile(u(r""" + ^ + \{FSHP + (\d+)\| # variant + (\d+)\| # salt size + (\d+)\} # rounds + ([a-zA-Z0-9+/]+={0,3}) # digest + $"""), re.X) @classmethod def from_string(cls, hash): @@ -141,7 +145,7 @@ class fshp(uh.HasStubChecksum, uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, u raise ValueError("no hash specified") if isinstance(hash, bytes): hash = hash.decode("ascii") - m = cls._fshp_re.match(hash) + m = cls._hash_regex.match(hash) if not m: raise ValueError("not a valid FSHP hash") variant, salt_size, rounds, data = m.group(1,2,3,4) diff --git a/passlib/handlers/ldap_digests.py b/passlib/handlers/ldap_digests.py index 38fe002..ce251eb 100644 --- a/passlib/handlers/ldap_digests.py +++ b/passlib/handlers/ldap_digests.py @@ -11,7 +11,9 @@ import re from warnings import warn #site #libs -from passlib.utils import to_native_str, unix_crypt_schemes +from passlib.handlers.misc import plaintext +from passlib.utils import to_native_str, unix_crypt_schemes, to_bytes, \ + classproperty from passlib.utils.compat import b, bytes, uascii_to_str, unicode, u import passlib.utils.handlers as uh #pkg @@ -44,47 +46,37 @@ class _Base64DigestHelper(uh.StaticHandler): ident = None #required - prefix identifier _hash_func = None #required - hash function - _pat = None #required - regexp to recognize hash + _hash_regex = None #required - regexp to recognize hash checksum_chars = uh.PADDED_BASE64_CHARS - @classmethod - def identify(cls, hash): - return uh.identify_regexp(hash, cls._pat) + @classproperty + def _hash_prefix(cls): + "tell StaticHandler to strip ident from checksum" + return cls.ident - @classmethod - def genhash(cls, secret, hash): - if secret is None: - raise TypeError("no secret provided") - if isinstance(secret, unicode): - secret = secret.encode("utf-8") - if hash is not None and not cls.identify(hash): - raise ValueError("not a %s hash" % (cls.name,)) - chk = cls._hash_func(secret).digest() - hash = cls.ident + b64encode(chk).decode("ascii") - return uascii_to_str(hash) + def _calc_checksum(self, secret): + secret = to_bytes(secret, "utf-8", errname="secret") + chk = self._hash_func(secret).digest() + return b64encode(chk).decode("ascii") -class _SaltedBase64DigestHelper(uh.HasStubChecksum, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler): +class _SaltedBase64DigestHelper(uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler): "helper for ldap_salted_md5 / ldap_salted_sha1" setting_kwds = ("salt",) checksum_chars = uh.PADDED_BASE64_CHARS ident = None #required - prefix identifier _hash_func = None #required - hash function - _pat = None #required - regexp to recognize hash + _hash_regex = None #required - regexp to recognize hash _stub_checksum = None #required - default checksum to plug in min_salt_size = max_salt_size = 4 @classmethod - def identify(cls, hash): - return uh.identify_regexp(hash, cls._pat) - - @classmethod def from_string(cls, hash): if not hash: raise ValueError("no hash specified") if isinstance(hash, bytes): hash = hash.decode('ascii') - m = cls._pat.match(hash) + m = cls._hash_regex.match(hash) if not m: raise ValueError("not a %s hash" % (cls.name,)) data = b64decode(m.group("tmp").encode("ascii")) @@ -116,7 +108,7 @@ class ldap_md5(_Base64DigestHelper): ident = u("{MD5}") _hash_func = md5 - _pat = re.compile(u(r"^\{MD5\}(?P<chk>[+/a-zA-Z0-9]{22}==)$")) + _hash_regex = re.compile(u(r"^\{MD5\}(?P<chk>[+/a-zA-Z0-9]{22}==)$")) class ldap_sha1(_Base64DigestHelper): """This class stores passwords using LDAP's plain SHA1 format, and follows the :ref:`password-hash-api`. @@ -128,7 +120,7 @@ class ldap_sha1(_Base64DigestHelper): ident = u("{SHA}") _hash_func = sha1 - _pat = re.compile(u(r"^\{SHA\}(?P<chk>[+/a-zA-Z0-9]{27}=)$")) + _hash_regex = re.compile(u(r"^\{SHA\}(?P<chk>[+/a-zA-Z0-9]{27}=)$")) class ldap_salted_md5(_SaltedBase64DigestHelper): """This class stores passwords using LDAP's salted MD5 format, and follows the :ref:`password-hash-api`. @@ -145,7 +137,7 @@ class ldap_salted_md5(_SaltedBase64DigestHelper): name = "ldap_salted_md5" ident = u("{SMD5}") _hash_func = md5 - _pat = re.compile(u(r"^\{SMD5\}(?P<tmp>[+/a-zA-Z0-9]{27}=)$")) + _hash_regex = re.compile(u(r"^\{SMD5\}(?P<tmp>[+/a-zA-Z0-9]{27}=)$")) _stub_checksum = b('\x00') * 16 class ldap_salted_sha1(_SaltedBase64DigestHelper): @@ -163,10 +155,10 @@ class ldap_salted_sha1(_SaltedBase64DigestHelper): name = "ldap_salted_sha1" ident = u("{SSHA}") _hash_func = sha1 - _pat = re.compile(u(r"^\{SSHA\}(?P<tmp>[+/a-zA-Z0-9]{32})$")) + _hash_regex = re.compile(u(r"^\{SSHA\}(?P<tmp>[+/a-zA-Z0-9]{32})$")) _stub_checksum = b('\x00') * 20 -class ldap_plaintext(uh.StaticHandler): +class ldap_plaintext(plaintext): """This class stores passwords in plaintext, and follows the :ref:`password-hash-api`. This class acts much like the generic :class:`!passlib.hash.plaintext` handler, @@ -175,8 +167,10 @@ class ldap_plaintext(uh.StaticHandler): Unicode passwords will be encoded using utf-8. """ - name = "ldap_plaintext" + # NOTE: this subclasses plaintext, since all it does differently + # is override identify() + name = "ldap_plaintext" _2307_pat = re.compile(u(r"^\{\w+\}.*$")) @classmethod @@ -185,22 +179,12 @@ class ldap_plaintext(uh.StaticHandler): return False if isinstance(hash, bytes): try: - hash = hash.decode("utf-8") + hash = hash.decode(cls._hash_encoding) except UnicodeDecodeError: return False - #NOTE: identifies all strings EXCEPT those which match... + # NOTE: identifies all strings EXCEPT those with {XXX} prefix return cls._2307_pat.match(hash) is None - @classmethod - def genhash(cls, secret, hash): - if hash is not None and not cls.identify(hash): - raise ValueError("not a valid ldap_plaintext hash") - return to_native_str(secret, "utf-8", errname="secret") - - @classmethod - def _norm_hash(cls, hash): - return to_native_str(hash, "utf-8", errname="hash") - #========================================================= #{CRYPT} wrappers #========================================================= diff --git a/passlib/handlers/misc.py b/passlib/handlers/misc.py index 06d9400..4244e14 100644 --- a/passlib/handlers/misc.py +++ b/passlib/handlers/misc.py @@ -8,8 +8,8 @@ import logging; log = logging.getLogger(__name__) from warnings import warn #site #libs -from passlib.utils import to_native_str -from passlib.utils.compat import bytes, unicode +from passlib.utils import to_native_str, consteq +from passlib.utils.compat import bytes, unicode, u import passlib.utils.handlers as uh #pkg #local @@ -37,55 +37,82 @@ class unix_fallback(uh.StaticHandler): """ name = "unix_fallback" context_kwds = ("enable_wildcard",) - _stub_config = "!" @classmethod def identify(cls, hash): return hash is not None - @classmethod - def genhash(cls, secret, hash, enable_wildcard=False): + def __init__(self, enable_wildcard=False, **kwds): + super(unix_fallback, self).__init__(**kwds) + self.enable_wildcard = enable_wildcard + + def _calc_checksum(self, secret): if secret is None: raise TypeError("secret must be string") - if hash is None: - raise ValueError("no hash provided") - # NOTE: hash will generally be "!" - return to_native_str(hash, "ascii", errname="hash") + if self.checksum: + # NOTE: hash will generally be "!", but we want to preserve + # it in case it's something else, like "*". + return self.checksum + else: + return u("!") @classmethod def verify(cls, secret, hash, enable_wildcard=False): - if hash is None: + if secret is None: + raise TypeError("secret must be string") + elif hash is None: raise ValueError("no hash provided") elif hash: return False else: return enable_wildcard -class plaintext(uh.StaticHandler): +class plaintext(object): """This class stores passwords in plaintext, and follows the :ref:`password-hash-api`. Unicode passwords will be encoded using utf-8. Under Python 3, existing 'hashes' must decode as utf-8. """ + # NOTE: this tries to avoid decoding bytes under py2, + # for applications that are using latin-1 or some other encoding. + # they'll just have to stop using plaintext under py3 :) + # (or re-encode as utf-8) + + # NOTE: this is subclassed by ldap_plaintext + name = "plaintext" + setting_kwds = () + context_kwds = () + _hash_encoding = "utf-8" @classmethod def identify(cls, hash): + # by default, identify ALL strings return hash is not None - # NOTE: this tries to avoid decoding bytes under py2, - # for applications that are using latin-1 or some other encoding. - # they'll just have to stop using plaintext under py3 :) - # (or re-encode as utf-8) + @classmethod + def encrypt(cls, secret): + return to_native_str(secret, cls._hash_encoding, "secret") @classmethod - def genhash(cls, secret, hash): - return to_native_str(secret, "utf-8", errname="secret") + def verify(cls, secret, hash): + if hash is None: + raise TypeError("no hash specified") + elif not cls.identify(hash): + raise ValueError("not a %s hash" % (cls.name,)) + hash = to_native_str(hash, cls._hash_encoding, "hash") + return consteq(cls.encrypt(secret), hash) @classmethod - def _norm_hash(cls, hash): - return to_native_str(hash, "utf-8", errname="hash") + def genconfig(cls): + return None + + @classmethod + def genhash(cls, secret, hash): + if hash is not None and not cls.identify(hash): + raise ValueError("not a %s hash" % (cls.name,)) + return cls.encrypt(secret) #========================================================= #eof diff --git a/passlib/handlers/mysql.py b/passlib/handlers/mysql.py index cab378b..cea160e 100644 --- a/passlib/handlers/mysql.py +++ b/passlib/handlers/mysql.py @@ -30,8 +30,9 @@ from warnings import warn #site #libs #pkg -from passlib.utils import to_native_str -from passlib.utils.compat import b, bascii_to_str, bytes, unicode, u, belem_ord +from passlib.utils import to_native_str, to_bytes +from passlib.utils.compat import b, bascii_to_str, bytes, unicode, u, \ + belem_ord, str_to_uascii import passlib.utils.handlers as uh #local __all__ = [ @@ -50,51 +51,41 @@ class mysql323(uh.StaticHandler): The :meth:`encrypt()` and :meth:`genconfig` methods accept no optional keywords. """ #========================================================= - #class attrs + # class attrs #========================================================= name = "mysql323" + checksum_size = 16 checksum_chars = uh.HEX_CHARS - _pat = re.compile(u(r"^[0-9a-f]{16}$"), re.I) - #========================================================= - #methods + # methods #========================================================= - - @classmethod - def identify(cls, hash): - return uh.identify_regexp(hash, cls._pat) - @classmethod - def genhash(cls, secret, config): - if config is not None and not cls.identify(config): - raise ValueError("not a mysql-3.2.3 hash") + def _norm_hash(cls, hash): + return hash.lower() - #FIXME: no idea if mysql has a policy about handling unicode passwords - if isinstance(secret, unicode): - secret = secret.encode("utf-8") + def _calc_checksum(self, secret): + # FIXME: no idea if mysql has a policy about handling unicode passwords + secret = to_bytes(secret, "utf-8", errname="secret") MASK_32 = 0xffffffff MASK_31 = 0x7fffffff + WHITE = b(' \t') nr1 = 0x50305735 nr2 = 0x12345671 add = 7 for c in secret: - if c in b(' \t'): + if c in WHITE: continue tmp = belem_ord(c) nr1 ^= ((((nr1 & 63)+add)*tmp) + (nr1 << 8)) & MASK_32 nr2 = (nr2+((nr2 << 8) ^ nr1)) & MASK_32 add = (add+tmp) & MASK_32 - return "%08x%08x" % (nr1 & MASK_31, nr2 & MASK_31) - - @classmethod - def _norm_hash(cls, hash): - return to_native_str(hash, "ascii", errname="hash").lower() + return u("%08x%08x") % (nr1 & MASK_31, nr2 & MASK_31) #========================================================= - #eoc + # eoc #========================================================= #========================================================= @@ -108,34 +99,27 @@ class mysql41(uh.StaticHandler): The :meth:`encrypt()` and :meth:`genconfig` methods accept no optional keywords. """ #========================================================= - #class attrs + # class attrs #========================================================= name = "mysql41" - _pat = re.compile(r"^\*[0-9A-F]{40}$", re.I) + _hash_prefix = u("*") + checksum_chars = uh.HEX_CHARS + checksum_size = 40 #========================================================= - #methods + # methods #========================================================= - @classmethod - def identify(cls, hash): - return uh.identify_regexp(hash, cls._pat) + def _norm_hash(cls, hash): + return hash.upper() - @classmethod - def genhash(cls, secret, config): - if config is not None and not cls.identify(config): - raise ValueError("not a mysql-4.1 hash") + def _calc_checksum(self, secret): # FIXME: no idea if mysql has a policy about handling unicode passwords - if isinstance(secret, unicode): - secret = secret.encode("utf-8") - return '*' + sha1(sha1(secret).digest()).hexdigest().upper() - - @classmethod - def _norm_hash(cls, hash): - return to_native_str(hash, "ascii", errname="hash").upper() + secret = to_bytes(secret, "utf-8", errname="secret") + return str_to_uascii(sha1(sha1(secret).digest()).hexdigest()).upper() #========================================================= - #eoc + # eoc #========================================================= #========================================================= diff --git a/passlib/handlers/nthash.py b/passlib/handlers/nthash.py index 6ad56c2..4b29b0e 100644 --- a/passlib/handlers/nthash.py +++ b/passlib/handlers/nthash.py @@ -21,7 +21,7 @@ __all__ = [ #========================================================= #handler #========================================================= -class nthash(uh.HasStubChecksum, uh.HasManyIdents, uh.GenericHandler): +class nthash(uh.HasManyIdents, uh.GenericHandler): """This class implements the NT Password hash in a manner compatible with the :ref:`modular-crypt-format`, and follows the :ref:`password-hash-api`. It has no salt and a single fixed round. @@ -56,16 +56,7 @@ class nthash(uh.HasStubChecksum, uh.HasManyIdents, uh.GenericHandler): @classmethod def from_string(cls, hash): - if not hash: - raise ValueError("no hash specified") - if isinstance(hash, bytes): - hash = hash.decode("ascii") - for ident in cls.ident_values: - if hash.startswith(ident): - break - else: - raise ValueError("invalid nthash") - chk = hash[len(ident):] + ident, chk = cls._parse_ident(hash) return cls(ident=ident, checksum=chk) def to_string(self): diff --git a/passlib/handlers/oracle.py b/passlib/handlers/oracle.py index 61ad27a..1322ea9 100644 --- a/passlib/handlers/oracle.py +++ b/passlib/handlers/oracle.py @@ -51,7 +51,7 @@ def des_cbc_encrypt(key, value, iv=b('\x00') * 8, pad=b('\x00')): #: magic string used as initial des key by oracle10 ORACLE10_MAGIC = b("\x01\x23\x45\x67\x89\xAB\xCD\xEF") -class oracle10(uh.StaticHandler): +class oracle10(uh.HasUserContext, uh.StaticHandler): """This class implements the password hash used by Oracle up to version 10g, and follows the :ref:`password-hash-api`. It has no salt and a single fixed round. @@ -64,62 +64,37 @@ class oracle10(uh.StaticHandler): :param user: string containing name of oracle user account this password is associated with. """ #========================================================= - #algorithm information + # algorithm information #========================================================= name = "oracle10" - setting_kwds = () - context_kwds = ("user",) + checksum_chars = uh.HEX_CHARS + checksum_size = 16 #========================================================= - #formatting + # methods #========================================================= - _pat = re.compile(u(r"^[0-9a-fA-F]{16}$")) - @classmethod - def identify(cls, hash): - return uh.identify_regexp(hash, cls._pat) - - #========================================================= - #primary interface - #========================================================= - @classmethod - def genhash(cls, secret, config, user): - if config is not None and not cls.identify(config): - raise ValueError("not an oracle-10g hash") - if secret is None: - raise TypeError("secret must be specified") - if not user: - raise ValueError("user keyword must be specified for this algorithm") + def _norm_hash(cls, hash): + return hash.upper() + def _calc_checksum(self, secret): #FIXME: not sure how oracle handles unicode. # online docs about 10g hash indicate it puts ascii chars - # in a 2-byte encoding w/ the high bytenull. - # they don't say how it handles other chars, - # or what encoding. + # in a 2-byte encoding w/ the high byte set to null. + # they don't say how it handles other chars, or what encoding. # - # so for now, encoding secret & user to utf-16-be, - # since that fits, + # so for now, encoding secret & user to utf-16-be, since that fits, # and if secret/user is bytes, we assume utf-8, and decode first. # # this whole mess really needs someone w/ an oracle system, # and some answers :) - def encode(value, errname): - "encode according to guess at how oracle encodes strings (see note above)" - #we can't trust what original encoding was. - #user should have passed us unicode in the first place. - #but try decoding as utf-8 just to work for most common case. - value = to_unicode(value, "utf-8", errname=errname) - return value.upper().encode("utf-16-be") - - input = encode(user, 'user') + encode(secret, 'secret') + secret = to_unicode(secret, "utf-8", errname="secret") + user = to_unicode(self.user, "utf-8", errname="user") + input = (user+secret).upper().encode("utf-16-be") hash = des_cbc_encrypt(ORACLE10_MAGIC, input) hash = des_cbc_encrypt(hash, input) - return bascii_to_str(hexlify(hash)).upper() - - @classmethod - def _norm_hash(cls, hash): - return to_native_str(hash, "ascii", errname="hash").upper() + return hexlify(hash).decode("ascii").upper() #========================================================= #eoc @@ -128,7 +103,7 @@ class oracle10(uh.StaticHandler): #========================================================= #oracle11 #========================================================= -class oracle11(uh.HasStubChecksum, uh.HasSalt, uh.GenericHandler): +class oracle11(uh.HasSalt, uh.GenericHandler): """This class implements the Oracle11g password hash, and follows the :ref:`password-hash-api`. It supports a fixed-length salt. @@ -159,11 +134,7 @@ class oracle11(uh.HasStubChecksum, uh.HasSalt, uh.GenericHandler): #========================================================= #methods #========================================================= - _pat = re.compile(u("^S:(?P<chk>[0-9a-f]{40})(?P<salt>[0-9a-f]{20})$"), re.I) - - @classmethod - def identify(cls, hash): - return uh.identify_regexp(hash, cls._pat) + _hash_regex = re.compile(u("^S:(?P<chk>[0-9a-f]{40})(?P<salt>[0-9a-f]{20})$"), re.I) @classmethod def from_string(cls, hash): @@ -171,7 +142,7 @@ class oracle11(uh.HasStubChecksum, uh.HasSalt, uh.GenericHandler): raise ValueError("no hash provided") if isinstance(hash, bytes): hash = hash.decode("ascii") - m = cls._pat.match(hash) + m = cls._hash_regex.match(hash) if not m: raise ValueError("invalid oracle-11g hash") salt, chk = m.group("salt", "chk") diff --git a/passlib/handlers/pbkdf2.py b/passlib/handlers/pbkdf2.py index 37dab77..477e09d 100644 --- a/passlib/handlers/pbkdf2.py +++ b/passlib/handlers/pbkdf2.py @@ -184,7 +184,7 @@ class cta_pbkdf2_sha1(uh.HasRounds, uh.HasRawSalt, uh.HasRawChecksum, uh.Generic #--HasROunds-- default_rounds = 10000 - min_rounds = 0 + min_rounds = 1 max_rounds = 2**32-1 rounds_cost = "linear" @@ -281,7 +281,7 @@ class dlitz_pbkdf2_sha1(uh.HasRounds, uh.HasSalt, uh.GenericHandler): #--HasROunds-- default_rounds = 10000 - min_rounds = 0 + min_rounds = 1 max_rounds = 2**32-1 rounds_cost = "linear" @@ -336,7 +336,7 @@ class dlitz_pbkdf2_sha1(uh.HasRounds, uh.HasSalt, uh.GenericHandler): #========================================================= #crowd #========================================================= -class atlassian_pbkdf2_sha1(uh.HasStubChecksum, uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler): +class atlassian_pbkdf2_sha1(uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler): """This class implements the PBKDF2 hash used by Atlassian. It supports a fixed-length salt, and a fixed number of rounds. diff --git a/passlib/handlers/phpass.py b/passlib/handlers/phpass.py index 3273d61..c093255 100644 --- a/passlib/handlers/phpass.py +++ b/passlib/handlers/phpass.py @@ -86,16 +86,7 @@ class phpass(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.GenericHandler): @classmethod def from_string(cls, hash): - if not hash: - raise ValueError("no hash specified") - if isinstance(hash, bytes): - hash = hash.decode('ascii') - for ident in cls.ident_values: - if hash.startswith(ident): - break - else: - raise ValueError("invalid phpass portable hash") - data = hash[len(ident):] + ident, data = cls._parse_ident(hash) rounds, salt, chk = data[0], data[1:9], data[9:] return cls( ident=ident, diff --git a/passlib/handlers/postgres.py b/passlib/handlers/postgres.py index c4b5f9a..63e7ddd 100644 --- a/passlib/handlers/postgres.py +++ b/passlib/handlers/postgres.py @@ -10,8 +10,8 @@ from warnings import warn #site #libs #pkg -from passlib.utils import to_unicode -from passlib.utils.compat import b, bytes, bascii_to_str, unicode, u +from passlib.utils import to_bytes +from passlib.utils.compat import b, bytes, str_to_uascii, unicode, u import passlib.utils.handlers as uh #local __all__ = [ @@ -21,7 +21,7 @@ __all__ = [ #========================================================= #handler #========================================================= -class postgres_md5(uh.StaticHandler): +class postgres_md5(uh.HasUserContext, uh.StaticHandler): """This class implements the Postgres MD5 Password hash, and follows the :ref:`password-hash-api`. It has no salt and a single fixed round. @@ -34,35 +34,20 @@ class postgres_md5(uh.StaticHandler): :param user: string containing name of postgres user account this password is associated with. """ #========================================================= - #algorithm information + # algorithm information #========================================================= name = "postgres_md5" - setting_kwds = () - context_kwds = ("user",) + _hash_prefix = u("md5") + checksum_chars = uh.HEX_CHARS + checksum_size = 32 #========================================================= - #formatting + # primary interface #========================================================= - _pat = re.compile(u(r"^md5[0-9a-f]{32}$")) - - @classmethod - def identify(cls, hash): - return uh.identify_regexp(hash, cls._pat) - - #========================================================= - #primary interface - #========================================================= - @classmethod - def genhash(cls, secret, config, user): - if config is not None and not cls.identify(config): - raise ValueError("not a postgres-md5 hash") - if not user: - raise ValueError("user keyword must be specified for this algorithm") - if isinstance(secret, unicode): - secret = secret.encode("utf-8") - if isinstance(user, unicode): - user = user.encode("utf-8") - return "md5" + md5(secret + user).hexdigest() + def _calc_checksum(self, secret): + secret = to_bytes(secret, "utf-8", errname="secret") + user = to_bytes(self.user, "utf-8", errname="user") + return str_to_uascii(md5(secret + user).hexdigest()) #========================================================= #eoc diff --git a/passlib/handlers/sha2_crypt.py b/passlib/handlers/sha2_crypt.py index 64274d9..bffa5c1 100644 --- a/passlib/handlers/sha2_crypt.py +++ b/passlib/handlers/sha2_crypt.py @@ -115,7 +115,7 @@ def _raw_sha_crypt(secret, salt, rounds, hash): # + # if i%2>0 then C else DP # - # The algorithm can be see as a series of paired even/odd rounds, + # The algorithm can be seen as a series of paired even/odd rounds, # with each pair performing 'C = md5(odd_data + md5(C + even_data))', # where even_data & odd_data cycle through a fixed series of # combinations of DP & DS, repeating every 42 rounds (since lcm(2,3,7)==42) @@ -123,7 +123,7 @@ def _raw_sha_crypt(secret, salt, rounds, hash): # This code takes advantage of these facts: it precalculates all possible # combinations, and then orders them into 21 pairs of even,odd values. # this allows the brunt of C stage to be performed in 42-round blocks, - # with minimal overhead. + # with minimal branching/concatenation overhead. # build array containing 42-round pattern as pairs of even & odd data. dp_dp = dp*2 @@ -281,7 +281,7 @@ class sha256_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandl #========================================================= #: regexp used to parse hashes - _pat = re.compile(u(r""" + _hash_regex = re.compile(u(r""" ^ \$5 (\$rounds=(?P<rounds>\d+))? @@ -302,7 +302,7 @@ class sha256_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandl raise ValueError("no hash specified") if isinstance(hash, bytes): hash = hash.decode("ascii") - m = cls._pat.match(hash) + m = cls._hash_regex.match(hash) if not m: raise ValueError("invalid sha256-crypt hash") rounds, salt1, salt2, chk = m.group("rounds", "salt1", "salt2", "chk") @@ -435,7 +435,7 @@ class sha512_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandl #========================================================= #: regexp used to parse hashes - _pat = re.compile(u(r""" + _hash_regex = re.compile(u(r""" ^ \$6 (\$rounds=(?P<rounds>\d+))? @@ -458,7 +458,7 @@ class sha512_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandl raise ValueError("no hash specified") if isinstance(hash, bytes): hash = hash.decode("ascii") - m = cls._pat.match(hash) + m = cls._hash_regex.match(hash) if not m: raise ValueError("invalid sha512-crypt hash") rounds, salt1, salt2, chk = m.group("rounds", "salt1", "salt2", "chk") diff --git a/passlib/handlers/sun_md5_crypt.py b/passlib/handlers/sun_md5_crypt.py index c441901..7d689e7 100644 --- a/passlib/handlers/sun_md5_crypt.py +++ b/passlib/handlers/sun_md5_crypt.py @@ -216,6 +216,8 @@ class sun_md5_crypt(uh.HasRounds, uh.HasSalt, uh.GenericHandler): #XXX: ^ not sure what it does if past this bound... does 32 int roll over? rounds_cost = "linear" + ident_values = (u("$md5$"), u("$md5,")) + #========================================================= #instance attrs #========================================================= @@ -233,7 +235,14 @@ class sun_md5_crypt(uh.HasRounds, uh.HasSalt, uh.GenericHandler): #========================================================= @classmethod def identify(cls, hash): - return uh.identify_prefix(hash, (u("$md5$"), u("$md5,"))) + if not hash: + return False + if isinstance(hash, bytes): + try: + hash = hash.decode("ascii") + except UnicodeDecodeError: + return False + return hash.startswith(cls.ident_values) @classmethod def from_string(cls, hash): diff --git a/passlib/tests/test_context.py b/passlib/tests/test_context.py index 151af5a..c3bf622 100644 --- a/passlib/tests/test_context.py +++ b/passlib/tests/test_context.py @@ -937,10 +937,9 @@ class CryptContextTest(TestCase): def identify(cls, hash): return True - @classmethod - def genhash(cls, secret, hash): + def _calc_checksum(self, secret): time.sleep(cls.delay) - return secret + 'x' + return to_unicode(secret + 'x') # silence deprecation warnings for min verify time with catch_warnings(record=True) as wlog: diff --git a/passlib/tests/test_utils_handlers.py b/passlib/tests/test_utils_handlers.py index 278d252..8ae5a96 100644 --- a/passlib/tests/test_utils_handlers.py +++ b/passlib/tests/test_utils_handlers.py @@ -39,61 +39,58 @@ def _makelang(alphabet, size): return set(helper(size)) #========================================================= -#test support classes - StaticHandler, GenericHandler, etc +#test GenericHandler & associates mixin classes #========================================================= class SkeletonTest(TestCase): "test hash support classes" #========================================================= - #StaticHandler + # StaticHandler #========================================================= def test_00_static_handler(self): - "test StaticHandler helper class" + "test StaticHandler class" class d1(uh.StaticHandler): name = "d1" context_kwds = ("flag",) + _hash_prefix = u("_") + checksum_chars = u("ab") + checksum_size = 1 - @classmethod - def genhash(cls, secret, hash, flag=False): - if isinstance(hash, bytes): - hash = hash.decode("ascii") - if hash not in (u('a'), u('b'), None): - raise ValueError("unknown hash %r" % (hash,)) - return 'b' if flag else 'a' + def __init__(self, flag=False, **kwds): + super(d1, self).__init__(**kwds) + self.flag = flag + + def _calc_checksum(self, secret): + return u('b') if self.flag else u('a') # check default identify method - self.assertTrue(d1.identify(u('a'))) - self.assertTrue(d1.identify(b('a'))) - self.assertTrue(d1.identify(u('b'))) + self.assertTrue(d1.identify(u('_a'))) + self.assertTrue(d1.identify(b('_a'))) + self.assertTrue(d1.identify(u('_b'))) + + self.assertFalse(d1.identify(u('_c'))) + self.assertFalse(d1.identify(b('_c'))) + self.assertFalse(d1.identify(u('a'))) + self.assertFalse(d1.identify(u('b'))) self.assertFalse(d1.identify(u('c'))) - self.assertFalse(d1.identify(b('c'))) - self.assertFalse(d1.identify(u(''))) self.assertFalse(d1.identify(None)) # check default genconfig method self.assertIs(d1.genconfig(), None) - d1._stub_config = u('b') - self.assertEqual(d1.genconfig(), 'b') - - # check config string is rejected - self.assertRaises(ValueError, d1.verify, 's', b('b')) - self.assertRaises(ValueError, d1.verify, 's', u('b')) - del d1._stub_config # check default verify method - self.assertTrue(d1.verify('s', b('a'))) - self.assertTrue(d1.verify('s',u('a'))) - self.assertFalse(d1.verify('s', b('b'))) - self.assertFalse(d1.verify('s',u('b'))) - self.assertTrue(d1.verify('s', b('b'), flag=True)) - self.assertRaises(ValueError, d1.verify, 's', b('c')) - self.assertRaises(ValueError, d1.verify, 's', u('c')) + self.assertTrue(d1.verify('s', b('_a'))) + self.assertTrue(d1.verify('s',u('_a'))) + self.assertFalse(d1.verify('s', b('_b'))) + self.assertFalse(d1.verify('s',u('_b'))) + self.assertTrue(d1.verify('s', b('_b'), flag=True)) + self.assertRaises(ValueError, d1.verify, 's', b('_c')) + self.assertRaises(ValueError, d1.verify, 's', u('_c')) # check default encrypt method - self.assertEqual(d1.encrypt('s'), 'a') - self.assertEqual(d1.encrypt('s'), 'a') - self.assertEqual(d1.encrypt('s', flag=True), 'b') + self.assertEqual(d1.encrypt('s'), '_a') + self.assertEqual(d1.encrypt('s', flag=True), '_b') #========================================================= #GenericHandler & mixins @@ -104,8 +101,10 @@ class SkeletonTest(TestCase): @classmethod def from_string(cls, hash): - if hash == 'a': - return cls(checksum='a') + if isinstance(hash, bytes): + hash = hash.decode("ascii") + if hash == u('a'): + return cls(checksum=hash) else: raise ValueError @@ -115,12 +114,21 @@ class SkeletonTest(TestCase): self.assertTrue(d1.identify('a')) self.assertFalse(d1.identify('b')) + # check regexp + d1._hash_regex = re.compile(u('@.')) + self.assertFalse(d1.identify(None)) + self.assertFalse(d1.identify('')) + self.assertTrue(d1.identify('@a')) + self.assertFalse(d1.identify('a')) + del d1._hash_regex + # check ident-based d1.ident = u('!') self.assertFalse(d1.identify(None)) self.assertFalse(d1.identify('')) self.assertTrue(d1.identify('!a')) self.assertFalse(d1.identify('a')) + del d1.ident def test_11_norm_checksum(self): "test GenericHandler checksum handling" @@ -128,22 +136,32 @@ class SkeletonTest(TestCase): class d1(uh.GenericHandler): name = 'd1' checksum_size = 4 - checksum_chars = 'x' + checksum_chars = u('xz') + _stub_checksum = u('z')*4 def norm_checksum(*a, **k): return d1(*a, **k).checksum # too small - self.assertRaises(ValueError, norm_checksum, 'xxx') + self.assertRaises(ValueError, norm_checksum, u('xxx')) # right size - self.assertEqual(norm_checksum('xxxx'), 'xxxx') + self.assertEqual(norm_checksum(u('xxxx')), u('xxxx')) + self.assertEqual(norm_checksum(u('xzxz')), u('xzxz')) # too large - self.assertRaises(ValueError, norm_checksum, 'xxxxx') + self.assertRaises(ValueError, norm_checksum, u('xxxxx')) # wrong chars - self.assertRaises(ValueError, norm_checksum, 'xxyx') + self.assertRaises(ValueError, norm_checksum, u('xxyx')) + + # wrong type + self.assertRaises(TypeError, norm_checksum, b('xxyx')) + + # test _stub_checksum behavior + self.assertIs(norm_checksum(u('zzzz')), None) + + # TODO: test HasRawChecksum mixin def test_20_norm_salt(self): "test GenericHandler + HasSalt mixin" @@ -220,6 +238,8 @@ class SkeletonTest(TestCase): self.assertEqual(len(gen_salt(5)), 5) self.consumeWarningList(wlog) + # TODO: test HasRawSalt mixin + def test_30_norm_rounds(self): "test GenericHandler + HasRounds mixin" # setup helpers @@ -504,22 +524,16 @@ class PrefixWrapperTest(TestCase): class UnsaltedHash(uh.StaticHandler): "test algorithm which lacks a salt" name = "unsalted_test_hash" - _stub_config = "0" * 40 - - @classmethod - def identify(cls, hash): - return uh.identify_regexp(hash, re.compile(u("^[0-9a-f]{40}$"))) + checksum_chars = uh.LOWER_HEX_CHARS + checksum_size = 40 - @classmethod - def genhash(cls, secret, hash): - if not cls.identify(hash): - raise ValueError("not a unsalted-example hash") + def _calc_checksum(self, secret): if isinstance(secret, unicode): secret = secret.encode("utf-8") data = b("boblious") + secret - return hashlib.sha1(data).hexdigest() + return str_to_uascii(hashlib.sha1(data).hexdigest()) -class SaltedHash(uh.HasStubChecksum, uh.HasSalt, uh.GenericHandler): +class SaltedHash(uh.HasSalt, uh.GenericHandler): "test algorithm with a salt" name = "salted_test_hash" setting_kwds = ("salt",) @@ -529,9 +543,7 @@ class SaltedHash(uh.HasStubChecksum, uh.HasSalt, uh.GenericHandler): checksum_size = 40 salt_chars = checksum_chars = uh.LOWER_HEX_CHARS - @classmethod - def identify(cls, hash): - return uh.identify_regexp(hash, re.compile(u("^@salt[0-9a-f]{42,44}$"))) + _hash_regex = re.compile(u("^@salt[0-9a-f]{42,44}$")) @classmethod def from_string(cls, hash): diff --git a/passlib/utils/handlers.py b/passlib/utils/handlers.py index 139143b..911bba2 100644 --- a/passlib/utils/handlers.py +++ b/passlib/utils/handlers.py @@ -1,9 +1,9 @@ """passlib.handler - code for implementing handlers, and global registry for handlers""" #========================================================= -#imports +# imports #========================================================= from __future__ import with_statement -#core +# core import inspect import re import hashlib @@ -11,30 +11,36 @@ import logging; log = logging.getLogger(__name__) import time import os from warnings import warn -#site -#libs -from passlib.exc import MissingBackendError, PasslibHashWarning, \ - PasslibRuntimeWarning +# site +# pkg +from passlib.exc import MissingBackendError, PasslibConfigWarning, \ + PasslibHashWarning from passlib.registry import get_crypt_handler -from passlib.utils import is_crypt_handler from passlib.utils import classproperty, consteq, getrandstr, getrandbytes,\ - BASE64_CHARS, HASH64_CHARS, rng, to_native_str + BASE64_CHARS, HASH64_CHARS, rng, to_native_str, \ + is_crypt_handler, deprecated_function, to_unicode from passlib.utils.compat import b, bjoin_ints, bytes, irange, u, \ - uascii_to_str, ujoin, unicode -#pkg -#local + uascii_to_str, ujoin, unicode, str_to_uascii +# local __all__ = [ - #framework for implementing handlers - 'StaticHandler', + # helpers for implementing MCF handlers + 'parse_mc2', + 'parse_mc3', + 'render_mc2', + 'render_mc3', + + # framework for implementing handlers 'GenericHandler', - # checksum mixins - 'HasRawChecksum', - 'HasStubChecksum', + 'StaticHandler', + 'HasUserContext', + 'HasRawChecksum', 'HasManyIdents', 'HasSalt', - 'HasRawSalt', + 'HasRawSalt', 'HasRounds', 'HasManyBackends', + + # other helpers 'PrefixWrapper', ] @@ -61,32 +67,6 @@ UC_HEX_CHARS = UPPER_HEX_CHARS LC_HEX_CHARS = LOWER_HEX_CHARS #========================================================= -#identify helpers -#========================================================= -def identify_regexp(hash, pat): - "identify() helper for matching regexp" - if not hash: - return False - if isinstance(hash, bytes): - try: - hash = hash.decode("ascii") - except UnicodeDecodeError: - return False - return pat.match(hash) is not None - -def identify_prefix(hash, prefix): - "identify() helper for matching against prefixes" - #NOTE: prefix may be a tuple of strings (since startswith supports that) - if not hash: - return False - if isinstance(hash, bytes): - try: - hash = hash.decode("ascii") - except UnicodeDecodeError: - return False - return hash.startswith(prefix) - -#========================================================= #parsing helpers #========================================================= def parse_mc2(hash, prefix, name="<unnamed>", sep=u("$")): @@ -149,119 +129,21 @@ def render_mc3(ident, rounds, salt, checksum, sep=u("$")): hash = u("%s%s%s%s") % (ident, rounds, sep, salt) return uascii_to_str(hash) -#===================================================== -#StaticHandler -#===================================================== -class StaticHandler(object): - """helper class for implementing hashes which have no settings. - - This class is designed to help in writing hash handlers - which have no settings whatsoever; that is to say: no salt, no rounds, etc. - These hashes can typically be recognized by the fact that they - will always hash a password to *exactly* the same hash string. - - Usage - ===== - - In order to use this class, just subclass it, and then do the following: - - * fill out the :attr:`name` attribute with the name of your hash. - * provide an implementation of the :meth:`~PasswordHash.genhash` method. - * provide an implementation of the :meth:`~PasswordHash.identify` method. - (a default is provided, but it's inefficient). - - Based on the methods above, this class provides: - - * a :meth:`genconfig` method that returns ``None``. - * a :meth:`encrypt` method that wraps :meth:`genhash`. - * a :meth:`verify` method that wraps :meth:`genhash`. - - Implementation Details - ====================== - - The :meth:`genhash` method you implement must accept - all valid hashes, *as well as* whatever value :meth:`genconfig` returns. - This defaults to ``None``, but you may set the :attr:`_stub_config` attr - to a specific hash string, and :meth:`genconfig` will return this instead. - - The default :meth:`verify` method uses simple equality to compare hash strings. - If your hash has multiple encodings (e.g. is case-insensitive), the - :meth:`_norm_hash` method should be overridden to normalize to a single - representation. - - If your hash has options, such as multiple identifiers, salts, - or variable rounds, this is not the right class to start with. - You should use the :class:`GenericHandler` class, or implement the handler - yourself. - """ - - #===================================================== - #class attrs - #===================================================== - name = None #required - handler name - setting_kwds = () - context_kwds = () - - # reserved value to be returned by default genconfig() - # may be ``None`` if no such value; otherwise should be native ascii str. - _stub_config = None - - #===================================================== - #methods - #===================================================== - @classmethod - def identify(cls, hash): - #NOTE: this relys on genhash() throwing error for invalid hashes. - # this approach is bad because genhash may take a long time on valid hashes, - # so subclasses *really* should override this. - if hash is None: - return False - try: - cls.genhash('fakesecret', hash) - return True - except ValueError: - return False - - @classmethod - def genconfig(cls): - "default genconfig() implementation for unsalted hash algorithms" - return cls._stub_config - - @classmethod - def genhash(cls, secret, config, **context): - raise NotImplementedError("%s subclass must implement genhash()" % (cls,)) - - @classmethod - def encrypt(cls, secret, *cargs, **context): - "default encrypt() implementation for unsalted hash algorithms" - # NOTE: subclasses generally won't need to override this - config = cls.genconfig() - return cls.genhash(secret, config, *cargs, **context) - - @classmethod - def verify(cls, secret, hash, *cargs, **context): - "default verify() implementation for unsalted hash algorithms" - # NOTE: subclasses generally won't need to override this. - if hash is None: - raise ValueError("no hash specified") - hash = cls._norm_hash(hash) - if hash == cls._stub_config: - raise ValueError("expected %s hash, got %s config string instead" % - (cls.name, cls.name)) - result = cls.genhash(secret, hash, *cargs, **context) - return consteq(result, hash) - - @classmethod - def _norm_hash(cls, hash): - """[helper for verify] normalize hash for comparsion purposes. - - should return a native :class:`str` instance or raise a TypeError. - """ - return to_native_str(hash, "ascii", errname="hash") - - #===================================================== - #eoc - #===================================================== +#========================================================================== +# not proper exceptions, just predefined error message constructors +# used by various handlers. +#========================================================================== +def ChecksumSizeError(handler, size, raw=False): + name = handler.name + unit = "bytes" if raw else "chars" + return ValueError("checksum wrong size (%s checksum must be " + "exactly %d %s" % (name, size, unit)) + +def MissingDigestError(handler): + "raised when verify() method gets passed config string instead of hash" + name = handler.name + return ValueError("expected %s hash, got %s config string instead" % + (name, name)) #===================================================== #GenericHandler @@ -316,6 +198,15 @@ class GenericHandler(object): This should be a unicode str. + .. attribute:: _hash_regex + + [optional] + If this attribute is filled in, the default :meth:`identify` method + will use it to recognize instances of the hash. If :attr:`ident` + is specified, this will be ignored. + + This should be a unique regex object. + .. attribute:: checksum_size [optional] @@ -330,11 +221,23 @@ class GenericHandler(object): This should be a unicode str. + .. attribute:: _stub_checksum + + [optional] + If specified, hashes with this checksum will have their checksum + normalized to ``None``, treating it like a config string. + This is mainly used by hash formats which don't have a concept + of a config string, so a unlikely-to-occur checksum (e.g. all zeros) + is used by some implementations. + + This should be a string of the same datatype as :attr:`checksum`, + or ``None``. + Instance Attributes =================== .. attribute:: checksum - The checksum string as provided by the constructor (after passing it + The checksum string provided to the constructor (after passing it through :meth:`_norm_checksum`). Required Subclass Methods @@ -347,8 +250,8 @@ class GenericHandler(object): Default Methods =============== - The following methods provide generally useful default behaviors, - though they may be overridden if the hash subclass needs to: + The following methods have default implementations that should work for + most cases, though they may be overridden if the hash subclass needs to: .. automethod:: _norm_checksum @@ -362,19 +265,38 @@ class GenericHandler(object): #===================================================== #class attr #===================================================== + # this must be provided by the actual class. + setting_kwds = None + + # providing default since most classes don't use this at all. context_kwds = () - ident = None #identifier prefix if known + # optional prefix that uniquely identifies hash + ident = None + + # optional regexp for recognizing hashes, + # used by default identify() if .ident isn't specified. + _hash_regex = None + + # if specified, _norm_checksum will require this length + checksum_size = None + + # if specified, _norm_checksum() will validate this + checksum_chars = None + + # if specified, hashes with this checksum will be treated + # as if no checksum was specified. + _stub_checksum = None - checksum_size = None #if specified, _norm_checksum will require this length - checksum_chars = None #if specified, _norm_checksum() will validate this + # private flag used by HasRawChecksum + _checksum_is_bytes = False #===================================================== #instance attrs #===================================================== checksum = None # stores checksum -# relaxed = False # when norm_xxx() funcs should be strict about inputs -# use_defaults = False # whether norm_xxx() funcs should fill in defaults. +# use_defaults = False # whether _norm_xxx() funcs should fill in defaults. +# relaxed = False # when _norm_xxx() funcs should be strict about inputs #===================================================== #init @@ -390,30 +312,43 @@ class GenericHandler(object): """validates checksum keyword against class requirements, returns normalized version of checksum. """ - # NOTE: this code assumes checksum should be a unicode string. - # For classes where the checksum is raw bytes, the HasRawChecksum - # mixin overrides this method with a more appropriate one. + # NOTE: by default this code assumes checksum should be unicode. + # For classes where the checksum is raw bytes, the HasRawChecksum sets + # the _checksum_is_bytes flag which alters various code paths below. if checksum is None: return None - # normalize to unicode - if isinstance(checksum, bytes): - checksum = checksum.decode('ascii') + # normalize to bytes / unicode + raw = self._checksum_is_bytes + if raw: + # NOTE: no clear route to reasonbly convert unicode -> raw bytes, + # so relaxed does nothing here + if not isinstance(checksum, bytes): + raise TypeError("checksum must be byte string") + + elif not isinstance(checksum, unicode): + if self.relaxed: + warn("checksum should be unicode, not bytes", + PasslibHashWarning) + checksum = checksum.decode("ascii") + else: + raise TypeError("checksum must be unicode string") + + # handle stub + if checksum == self._stub_checksum: + return None # check size cc = self.checksum_size if cc and len(checksum) != cc: - raise ValueError("checksum wrong size (%s checksum must be " - "exactly %d characters" % (self.name, cc)) + raise ChecksumSizeError(self, cc, raw=raw) # check charset - cs = self.checksum_chars - if cs: - bad = set(checksum) - bad.difference_update(cs) - if bad: - raise ValueError("invalid characters in %s checksum: %r" % - (self.name, ujoin(sorted(bad)))) + if not raw: + cs = self.checksum_chars + if cs and any(c not in cs for c in checksum): + raise ValueError("invalid characters in %s checksum" % + (self.name,)) return checksum @@ -425,29 +360,43 @@ class GenericHandler(object): # NOTE: subclasses may wish to use faster / simpler identify, # and raise value errors only when an invalid (but identifiable) # string is parsed + if not hash: return False + + # does class specify a known unique prefix to look for? ident = cls.ident - if ident: - #class specified a known prefix to look for + if ident is not None: assert isinstance(ident, unicode) if isinstance(hash, bytes): ident = ident.encode('ascii') return hash.startswith(ident) - else: - # don't have known ident prefix; so as fallback, try to parse hash - # to trying to parse hash and see if we succeed. - # (inefficient, but works for most cases) - try: - cls.from_string(hash) - return True - except ValueError: - return False + + # does class provide a regexp to use? + pat = cls._hash_regex + if pat is not None: + if isinstance(hash, bytes): + try: + hash = hash.decode("ascii") + except UnicodeDecodeError: + return False + return pat.match(hash) is not None + + # as fallback, try to parse hash, and see if we succeed. + # inefficient, but works for most cases. + try: + cls.from_string(hash) + return True + except ValueError: + return False @classmethod - def from_string(cls, hash): #pragma: no cover + def from_string(cls, hash, **context): #pragma: no cover """return parsed instance from hash/configuration string + :param \*\*context: + context keywords to pass to constructor (if applicable). + :raises ValueError: if hash is incorrectly formatted :returns: @@ -477,15 +426,12 @@ class GenericHandler(object): ##def to_config_string(self): ## "helper for generating configuration string (ignoring hash)" - ## chk = self.checksum - ## if chk: - ## try: - ## self.checksum = None - ## return self.to_string() - ## finally: - ## self.checksum = chk - ## else: + ## orig = self.checksum + ## try: + ## self.checksum = None ## return self.to_string() + ## finally: + ## self.checksum = orig #========================================================= #'crypt-style' interface (default implementation) @@ -495,8 +441,8 @@ class GenericHandler(object): return cls(use_defaults=True, **settings).to_string() @classmethod - def genhash(cls, secret, config): - self = cls.from_string(config) + def genhash(cls, secret, config, **context): + self = cls.from_string(config, **context) self.checksum = self._calc_checksum(secret) return self.to_string() @@ -511,33 +457,171 @@ class GenericHandler(object): #'application' interface (default implementation) #========================================================= @classmethod - def encrypt(cls, secret, **settings): - self = cls(use_defaults=True, **settings) + def encrypt(cls, secret, **kwds): + self = cls(use_defaults=True, **kwds) self.checksum = self._calc_checksum(secret) return self.to_string() @classmethod - def verify(cls, secret, hash): - #NOTE: classes with multiple checksum encodings (rare) - # may wish to either override this, or override _norm_checksum - # to normalize any checksums provided by from_string() - self = cls.from_string(hash) + def verify(cls, secret, hash, **context): + # NOTE: classes with multiple checksum encodings should either + # override this method, or ensure that from_string() / _norm_checksum() + # ensures .checksum always uses a single canonical representation. + self = cls.from_string(hash, **context) chk = self.checksum if chk is None: - raise ValueError("expected %s hash, got %s config string instead" % - (cls.name, cls.name)) + raise MissingDigestError(cls) return consteq(self._calc_checksum(secret), chk) #========================================================= + # undocumented entry points + #========================================================= + + ##@classmethod + ##def _deprecation_detector(cls, **settings): + ## """return helper to detect deprecated hashes. + ## + ## if this method is defined, the CryptContext constructor + ## will invoke it with the settings specified for the context. + ## this method should return None or a callable + ## with the signature ``func(hash)->bool``. + ## + ## this function should return true if the hash + ## should be re-encrypted, whether due to internal + ## issues or the specified settings. + ## + ## CryptContext will automatically take care of rounds-deprecation + ## for GenericHandler-derived classes + ## """ + + ##@classmethod + ##def normhash(cls, hash): + ## """helper to clean up non-canonic instances of hash. + ## currently only provided by bcrypt() to fix an historical passlib issue. + ## """ + + #========================================================= #eoc #========================================================= +class StaticHandler(GenericHandler): + """GenericHandler mixin for classes which have no settings. + + This mixin assumes the entirety of the hash ise stored in the + :attr:`checksum` attribute; that the hash has no rounds, salt, + etc. This class provides the following: + + * a default :meth:`genconfig` that always returns None. + * a default :meth:`from_string` and :meth:`to_string` + that store the entire hash within :attr:`checksum`, + after optionally stripping a constant prefix. + + All that is required by subclasses is an implementation of + the :meth:`_calc_checksum` method. + """ + # TODO: document _norm_hash() + + setting_kwds = () + + # optional constant prefix subclasses can specify + _hash_prefix = u("") + + @classmethod + def from_string(cls, hash, **context): + # default from_string() which strips optional prefix, + # and passes rest unchanged as checksum value. + hash = to_unicode(hash, "ascii", errname="hash") + hash = cls._norm_hash(hash) + # could enable this for extra strictness + ##pat = cls._hash_regex + ##if pat and pat.match(hash) is None: + ## raise ValueError("not a valid %s hash" % (cls.name,)) + prefix = cls._hash_prefix + if prefix: + if hash.startswith(prefix): + hash = hash[len(prefix):] + else: + raise ValueError("not a valid %s hash" % (cls.name,)) + return cls(checksum=hash, **context) + + @classmethod + def _norm_hash(cls, hash): + "helper for subclasses to normalize case if needed" + return hash + + def to_string(self): + assert self.checksum is not None + return uascii_to_str(self._hash_prefix + self.checksum) + + @classmethod + def genconfig(cls): + # since it has no settings, there's no need for a config string. + return None + + @classmethod + def genhash(cls, secret, config, **context): + # since it has no settings, just verify config, and call encrypt() + if config is not None and not cls.identify(config): + raise ValueError("not a %s hash" % (cls.name,)) + return cls.encrypt(secret, **context) + + __cc_compat_hack = False + + def _calc_checksum(self, secret): #pragma: no cover + """given secret; calcuate and return encoded checksum portion of hash + string, taking config from object state + """ + # NOTE: prior to 1.6, StaticHandler required classes implement genhash + # instead of this method. so if we reach here, we try calling genhash. + # if that succeeds, we issue deprecation warning; if it fails, we'll + # recurse back to here, and error will be thrown instead. + if not self.__cc_compat_hack: + context = dict((k,getattr(self,k)) for k in self.context_kwds) + self.__cc_compat_hack = True + hash = self.genhash(secret, None, **context) + self.__cc_compat_hack = False + warn("%r should be updated to implement StaticHandler._calc_checksum() " + "instead of StaticHandler.genhash(), support for the latter " + "style will be removed in Passlib 1.8" % (self.__class__), + DeprecationWarning) + return str_to_uascii(hash) + else: + # else just require subclass to implement this method. + raise NotImplementedError("%s must implement _calc_checksum()" % + (self.__class__,)) + #===================================================== #GenericHandler mixin classes #===================================================== +class HasUserContext(GenericHandler): + """helper for classes which require a user context keyword""" + context_kwds = ("user",) + + def __init__(self, user=None, **kwds): + super(HasUserContext, self).__init__(**kwds) + self.user = user + + # XXX: would like to validate user input here, but calls to from_string() + # which lack context keywords would then fail; so leaving code per-handler. -#XXX: add a HasContext helper to override GenericHandler's methods? + # wrap funcs to accept 'user' as positional arg for ease of use. + @classmethod + def encrypt(cls, secret, user=None, **context): + return super(HasUserContext, cls).encrypt(secret, user=user, **context) + + @classmethod + def verify(cls, secret, hash, user=None, **context): + return super(HasUserContext, cls).verify(secret, hash, user=user, + **context) + + @classmethod + def genhash(cls, secret, config, user=None, **context): + return super(HasUserContext, cls).genhash(secret, config, user=user, + **context) +#----------------------------------------------------- +# checksum mixins +#----------------------------------------------------- class HasRawChecksum(GenericHandler): """mixin for classes which work with decoded checksum bytes @@ -547,66 +631,14 @@ class HasRawChecksum(GenericHandler): """ # NOTE: GenericHandler.checksum_chars is ignored by this implementation. - def _norm_checksum(self, checksum): - if checksum is None: - return None - if isinstance(checksum, unicode): - raise TypeError("checksum must be specified as bytes") - cc = self.checksum_size - if cc and len(checksum) != cc: - raise ValueError("checksum wrong size (%s checksum must be " - "exactly %d characters" % (self.name, cc)) - return checksum - -class HasStubChecksum(GenericHandler): - """modifies class to ignore placeholder checksum used by genconfig(). - - this is mainly useful for hash formats which don't have a distinguishable - configuration-only format; and genconfig() has to use a placeholder - digest (usually all NULLs). this mixin causes that checksum to be - treated as if there wasn't a checksum at all; preventing the (remote) - chance of a configuration string 1) being stored as a hash, followed by - 2) an attacker finding and trying a password which correctly maps to that - digest. - """ - _stub_checksum = None - - def __init__(self, **kwds): - super(HasStubChecksum, self).__init__(**kwds) - chk = self.checksum - if chk is not None and chk == self._stub_checksum: - self.checksum = None - -#NOTE: commented out because all use-cases work better with StaticHandler -##class HasNoSettings(GenericHandler): -## """overrides some GenericHandler methods w/ versions more appropriate for hash w/no settings""" -## -## setting_kwds = () -## -## _stub_checksum = None -## -## @classmethod -## def genconfig(cls): -## if cls._stub_checksum: -## return cls().to_string() -## else: -## return None -## -## @classmethod -## def genhash(cls, secret, config): -## if config is None and not cls._stub_checksum: -## self = cls() -## else: -## self = cls.from_string(config) #just to validate the input -## self.checksum = self._calc_checksum(secret) -## return self.to_string() -## -## @classmethod -## def encrypt(cls, secret): -## self = cls() -## self.checksum = self._calc_checksum(secret) -## return self.to_string() + # NOTE: all HasRawChecksum code is currently part of GenericHandler, + # using private '_checksum_is_bytes' flag. + # this arrangement may be changed in the future. + _checksum_is_bytes = True +#----------------------------------------------------- +# ident mixins +#----------------------------------------------------- class HasManyIdents(GenericHandler): """mixin for hashes which use multiple prefix identifiers @@ -692,10 +724,25 @@ class HasManyIdents(GenericHandler): return False return any(hash.startswith(ident) for ident in cls.ident_values) + @classmethod + def _parse_ident(cls, hash): + """extract ident prefix from hash, helper for subclasses' from_string()""" + if not hash: + raise ValueError("no hash specified") + if isinstance(hash, bytes): + hash = hash.decode("ascii") + for ident in cls.ident_values: + if hash.startswith(ident): + return ident, hash[len(ident):] + raise ValueError("invalid %s hash" % (cls.name,)) + #========================================================= #eoc #========================================================= +#----------------------------------------------------- +# salt mixins +#----------------------------------------------------- class HasSalt(GenericHandler): """mixin for validating salts. @@ -766,7 +813,8 @@ class HasSalt(GenericHandler): .. automethod:: _norm_salt .. automethod:: _generate_salt """ - #XXX: allow providing raw salt to this class, and encoding it? + # TODO: document _truncate_salt() + # XXX: allow providing raw salt to this class, and encoding it? #========================================================= #class attrs @@ -839,6 +887,7 @@ class HasSalt(GenericHandler): raise TypeError("salt must be specified as bytes") else: if not isinstance(salt, unicode): + # XXX: should we disallow bytes here? if isinstance(salt, bytes): salt = salt.decode("ascii") else: @@ -846,12 +895,8 @@ class HasSalt(GenericHandler): # check charset sc = self.salt_chars - if sc is not None: - bad = set(salt) - bad.difference_update(sc) - if bad: - raise ValueError("invalid characters in %s salt: %r" % - (self.name, ujoin(sorted(bad)))) + if sc is not None and any(c not in sc for c in salt): + raise ValueError("invalid characters in %s salt" % self.name) # check min size mn = self.min_salt_size @@ -904,9 +949,8 @@ class HasRawSalt(HasSalt): salt_chars = ALL_BYTE_VALUES - #NOTE: all HasRawSalt code is currently part of HasSalt, - # using private _salt_is_bytes flag. - # this arrangement may be changed in the future. + # NOTE: all HasRawSalt code is currently part of HasSalt, using private + # '_salt_is_bytes' flag. this arrangement may be changed in the future. _salt_is_bytes = True _salt_unit = "bytes" @@ -914,6 +958,9 @@ class HasRawSalt(HasSalt): assert self.salt_chars in [None, ALL_BYTE_VALUES] return getrandbytes(rng, salt_size) +#----------------------------------------------------- +# rounds mixin +#----------------------------------------------------- class HasRounds(GenericHandler): """mixin for validating rounds parameter @@ -1043,6 +1090,9 @@ class HasRounds(GenericHandler): #eoc #========================================================= +#----------------------------------------------------- +# backend mixin & helpers +#----------------------------------------------------- def _clear_backend(cls): "restore HasManyBackend subclass to unloaded state - used by unittests" assert issubclass(cls, HasManyBackends) and cls is not HasManyBackends @@ -1138,11 +1188,10 @@ class HasManyBackends(GenericHandler): ``True`` if backend is currently supported, else ``False``. """ if name in ("any", "default"): - try: - cls.set_backend() + if name == "any" and cls._backend: return True - except MissingBackendError: - return False + return any(getattr(cls, "_has_backend_" + name) + for name in cls.backends) elif name in cls.backends: return getattr(cls, "_has_backend_" + name) else: @@ -1198,7 +1247,8 @@ class HasManyBackends(GenericHandler): else: raise MissingBackendError(cls._no_backends_msg()) elif not cls.has_backend(name): - raise MissingBackendError("%s backend not available: %r" % (cls.name, name)) + raise MissingBackendError("%s backend not available: %r" % + (cls.name, name)) cls._calc_checksum = getattr(cls, "_calc_checksum_" + name) cls._backend = name return name @@ -1325,6 +1375,8 @@ class PrefixWrapper(object): _proxy_attrs = ( "setting_kwds", "context_kwds", "default_rounds", "min_rounds", "max_rounds", "rounds_cost", + "default_salt_size", "min_salt_size", "max_salt_size", + "salt_chars", "default_salt_chars", "backends", "has_backend", "get_backend", "set_backend", ) |