"""passlib.handler - code for implementing handlers, and global registry for handlers""" #========================================================= #imports #========================================================= from __future__ import with_statement #core import inspect import re import hashlib import logging; log = logging.getLogger(__name__) import time import os from warnings import warn #site #libs from passlib.utils import classproperty, h64, getrandstr, rng, is_crypt_handler #pkg #local __all__ = [ #framework for implementing handlers 'BaseHash', 'ExtHash', 'StaticHash', 'BackendMixin', 'BackendExtHash', 'BackendStaticHash', ] #========================================================= #base handler #========================================================= class BaseHash(object): """helper for implementing password hash handler with minimal methods hash implementations should fill out the following: * all required class attributes: name, setting_kwds * classmethods genconfig() and genhash() many implementations will want to override the following: * classmethod identify() can usually be done more efficiently most implementations can use defaults for the following: * encrypt(), verify() note this class does not support context kwds of any type, since that is a rare enough requirement inside passlib. implemented subclasses may call cls.validate_class() to check attribute consistency (usually only required in unittests, etc) """ #===================================================== #required attributes #===================================================== name = None #required by subclass setting_kwds = None #required by subclass context_kwds = () #===================================================== #init #===================================================== @classmethod def validate_class(cls): "helper to ensure class is configured property" if not cls.name: raise AssertionError, "class must have .name attribute set" if cls.setting_kwds is None: raise AssertionError, "class must have .setting_kwds attribute set" #===================================================== #init helpers #===================================================== @classproperty def _has_settings(cls): "attr for checking if class has ANY settings, memoizes itself on first use" if cls.name is None: #otherwise this would optimize itself away prematurely raise RuntimeError, "_has_settings must be called on subclass only: %r" % (cls,) value = cls._has_settings = bool(cls.setting_kwds) return value #===================================================== #formatting (usually subclassed) #===================================================== @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. try: cls.genhash('stub', hash) return True except ValueError: return False #===================================================== #primary interface (must be subclassed) #===================================================== @classmethod def genconfig(cls, **settings): if cls._has_settings: raise NotImplementedError, "%s subclass must implement genconfig()" % (cls,) else: if settings: raise TypeError, "%s genconfig takes no kwds" % (cls.name,) return None @classmethod def genhash(cls, secret, config): raise NotImplementedError, "%s subclass must implement genhash()" % (cls,) #===================================================== #secondary interface (rarely subclassed) #===================================================== @classmethod def encrypt(cls, secret, **settings): config = cls.genconfig(**settings) return cls.genhash(secret, config) @classmethod def verify(cls, secret, hash): if not hash: raise ValueError, "no hash specified" return hash == cls.genhash(secret, hash) #===================================================== #eoc #===================================================== #========================================================= # ExtHash # rounds+salt+xtra phpass, sha256_crypt, sha512_crypt # rounds+salt bcrypt, ext_des_crypt, sha1_crypt, sun_md5_crypt # salt only apr_md5_crypt, des_crypt, md5_crypt #========================================================= class ExtHash(BaseHash): """helper class for implementing hash schemes hash implementations should fill out the following: * all required class attributes: - name, setting_kwds - max_salt_chars, min_salt_chars - only if salt is used - max_rounds, min_rounds, default_rounds - only if rounds are used * classmethod from_string() * instancemethod to_string() * instancemethod calc_checksum() many implementations will want to override the following: * classmethod identify() can usually be done more efficiently * checksum_charset, checksum_chars attributes may prove helpful for validation most implementations can use defaults for the following: * genconfig(), genhash(), encrypt(), verify(), etc * norm_checksum() usually only needs overriding if checksum has multiple encodings * salt_charset, default_salt_charset, default_salt_chars - if does not match common case note this class does not support context kwds of any type, since that is a rare enough requirement inside passlib. implemented subclasses may call cls.validate_class() to check attribute consistency (usually only required in unittests, etc) """ #========================================================= #class attributes #========================================================= #---------------------------------------------- #password hash api - required attributes #---------------------------------------------- name = None #required by ExtHash setting_kwds = None #required by ExtHash context_kwds = () #---------------------------------------------- #checksum information #---------------------------------------------- checksum_charset = None #if specified, norm_checksum() will validate this checksum_chars = None #if specified, norm_checksum will require this length #---------------------------------------------- #salt information #---------------------------------------------- max_salt_chars = None #required by ExtHash.norm_salt() @classproperty def min_salt_chars(cls): "min salt chars (defaults to max_salt_chars if not specified by subclass)" return cls.max_salt_chars @classproperty def default_salt_chars(cls): "default salt chars (defaults to max_salt_chars if not specified by subclass)" return cls.max_salt_chars salt_charset = h64.CHARS @classproperty def default_salt_charset(cls): return cls.salt_charset #---------------------------------------------- #rounds information #---------------------------------------------- min_rounds = 0 max_rounds = None #required by ExtHash.norm_rounds() default_rounds = None #if not specified, ExtHash.norm_rounds() will require explicit rounds value every time rounds_cost = "linear" #common case #---------------------------------------------- #misc ExtHash configuration #---------------------------------------------- _strict_rounds_bounds = False #if true, always raises error if specified rounds values out of range - required by spec for some hashes _extra_init_settings = () #settings that ExtHash.__init__ should handle by calling norm_() #========================================================= #instance attributes #========================================================= checksum = None salt = None rounds = None #========================================================= #init #========================================================= #XXX: rename strict kwd to _strict ? #XXX: for from_string() purposes, a strict_salt kwd to override strict, might also be useful def __init__(self, checksum=None, salt=None, rounds=None, strict=False, **kwds): self.checksum = self.norm_checksum(checksum, strict=strict) self.salt = self.norm_salt(salt, strict=strict) self.rounds = self.norm_rounds(rounds, strict=strict) extra = self._extra_init_settings if extra: for key in extra: value = kwds.pop(key, None) norm = getattr(self, "norm_" + key) value = norm(value, strict=strict) setattr(self, key, value) super(ExtHash, self).__init__(**kwds) @classmethod def validate_class(cls): "helper to ensure class is configured property" super(ExtHash, cls).validate_class() if any(k not in cls.setting_kwds for k in cls._extra_init_settings): raise AssertionError, "_extra_init_settings must be subset of setting_kwds" if 'salt' in cls.setting_kwds: if cls.min_salt_chars > cls.max_salt_chars: raise AssertionError, "min salt chars too large" if cls.default_salt_chars < cls.min_salt_chars: raise AssertionError, "default salt chars too small" if cls.default_salt_chars > cls.max_salt_chars: raise AssertionError, "default salt chars too large" if any(c not in cls.salt_charset for c in cls.default_salt_charset): raise AssertionError, "default salt charset not subset of salt charset" if 'rounds' in cls.setting_kwds: if cls.max_rounds is None: raise AssertionError, "max rounds not specified" if cls.min_rounds > cls.max_rounds: raise AssertionError, "min rounds too large" if cls.default_rounds is not None: if cls.default_rounds < cls.min_rounds: raise AssertionError, "default rounds too small" if cls.default_rounds > cls.max_rounds: raise AssertionError, "default rounds too large" if cls.rounds_cost not in ("linear", "log2"): raise AssertionError, "unknown rounds cost function" #========================================================= #init helpers #========================================================= #--------------------------------------------------------- #internal tests for features #--------------------------------------------------------- @classproperty def _has_salt(cls): "attr for checking if salts are supported, memoizes itself on first use" if cls is ExtHash: raise RuntimeError, "not allowed for ExtHash directly" value = cls._has_salt = 'salt' in cls.setting_kwds return value @classproperty def _has_rounds(cls): "attr for checking if variable are supported, memoizes itself on first use" if cls is ExtHash: raise RuntimeError, "not allowed for ExtHash directly" value = cls._has_rounds = 'rounds' in cls.setting_kwds return value #--------------------------------------------------------- #normalization/validation helpers #--------------------------------------------------------- @classmethod def norm_checksum(cls, checksum, strict=False): if checksum is None: return None cc = cls.checksum_chars if cc and len(checksum) != cc: raise ValueError, "%s checksum must be %d characters" % (cls.name, cc) cs = cls.checksum_charset if cs and any(c not in cs for c in checksum): raise ValueError, "invalid characters in %s checksum" % (cls.name,) return checksum @classmethod def norm_salt(cls, salt, strict=False): """helper to normalize & validate user-provided salt string :arg salt: salt string or ``None`` :param strict: enable strict checking (see below); disabled by default :raises ValueError: * if ``strict=True`` and no salt is provided * if ``strict=True`` and salt contains greater than :attr:`max_salt_chars` characters * if salt contains chars that aren't in :attr:`salt_charset`. * if salt contains less than :attr:`min_salt_chars` characters. if no salt provided and ``strict=False``, a random salt is generated using :attr:`default_salt_chars` and :attr:`default_salt_charset`. if the salt is longer than :attr:`max_salt_chars` and ``strict=False``, the salt string is clipped to :attr:`max_salt_chars`. :returns: normalized or generated salt """ if not cls._has_salt: #NOTE: special casing schemes which have no salt... if salt is not None: raise ValueError, "%s does not support ``salt`` parameter" % (cls.name,) return None if salt is None: if strict: raise ValueError, "no salt specified" return getrandstr(rng, cls.default_salt_charset, cls.default_salt_chars) #TODO: run salt_charset tests sc = cls.salt_charset if sc: for c in salt: if c not in sc: raise ValueError, "invalid character in %s salt: %r" % (cls.name, c) mn = cls.min_salt_chars if mn and len(salt) < mn: raise ValueError, "%s salt string must be at least %d characters" % (cls.name, mn) mx = cls.max_salt_chars if len(salt) > mx: if strict: raise ValueError, "%s salt string must be at most %d characters" % (cls.name, mx) salt = salt[:mx] return salt @classmethod def norm_rounds(cls, rounds, strict=False): """helper routine for normalizing rounds :arg rounds: rounds integer or ``None`` :param strict: enable strict checking (see below); disabled by default :raises ValueError: * if rounds is ``None`` and ``strict=True`` * if rounds is ``None`` and no :attr:`default_rounds` are specified by class. * if rounds is outside bounds of :attr:`min_rounds` and :attr:`max_rounds`, and ``strict=True``. if rounds are not specified and ``strict=False``, uses :attr:`default_rounds`. if rounds are outside bounds and ``strict=False``, rounds are clipped as appropriate, but a warning is issued. :returns: normalized rounds value """ if not cls._has_rounds: #NOTE: special casing schemes which don't have rounds if rounds is not None: raise ValueError, "%s does not support ``rounds``" % (cls.name,) return None if rounds is None: if strict: raise ValueError, "no rounds specified" rounds = cls.default_rounds if rounds is None: raise ValueError, "%s rounds value must be specified explicitly" % (cls.name,) return rounds if cls._strict_rounds_bounds: strict = True mn = cls.min_rounds if rounds < mn: if strict: raise ValueError, "%s rounds must be >= %d" % (cls.name, mn) warn("%s does not allow less than %d rounds: %d" % (cls.name, mn, rounds)) rounds = mn mx = cls.max_rounds if rounds > mx: if strict: raise ValueError, "%s rounds must be <= %d" % (cls.name, mx) warn("%s does not allow more than %d rounds: %d" % (cls.name, mx, rounds)) rounds = mx return rounds #========================================================= #password hash api - formatting interface #========================================================= @classmethod def identify(cls, hash): #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 try: cls.from_string(hash) return True except ValueError: return False @classmethod def from_string(cls, hash): "return parsed instance from hash/configuration string; raising ValueError on invalid inputs" raise NotImplementedError, "%s must implement from_string()" % (cls,) def to_string(self): "render instance to hash or configuration string (depending on if checksum attr is set)" raise NotImplementedError, "%s must implement from_string()" % (type(self),) ##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: ## return self.to_string() #========================================================= #password hash api - primary interface (default implementation) #========================================================= @classmethod def genconfig(cls, **settings): return cls(**settings).to_string() @classmethod def genhash(cls, secret, config): self = cls.from_string(config) self.checksum = self.calc_checksum(secret) return self.to_string() def calc_checksum(self, secret): "given secret; calcuate and return encoded checksum portion of hash string, taking config from object state" raise NotImplementedError, "%s must implement calc_checksum()" % (cls,) #========================================================= #password hash api - secondary interface (default implementation) #========================================================= @classmethod def encrypt(cls, secret, **settings): self = cls(**settings) 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) return self.checksum == self.calc_checksum(secret) #========================================================= #eoc #========================================================= #========================================================= #static - mysql_323, mysql_41, nthash, postgres_md5 #========================================================= class StaticHash(ExtHash): """helper class optimized for implementing hash schemes which have NO settings whatsoever. the main thing this changes from ExtHash: * :attr:`setting_kwds` must be an empty tuple (set by class) * :meth:`genconfig` takes no kwds, and always returns ``None``. * :meth:`genhash` accepts ``config=None``. otherwise, this requires the same methods be implemented as does ExtHash. """ #========================================================= #class attr #========================================================= setting_kwds = () #========================================================= #init #========================================================= @classmethod def validate_class(cls): "helper to validate that class has been configured properly" if cls.setting_kwds: raise AssertionError, "StaticHash subclasses must not have any settings, perhaps you want ExtHash?" super(StaticHash, cls).validate_class() #========================================================= #primary interface #========================================================= @classmethod def genconfig(cls): return None @classmethod def genhash(cls, secret, config): if config is None: self = cls() else: #just to verify input is correctly formatted self = cls.from_string(config) self.checksum = self.calc_checksum(secret) return self.to_string() #========================================================= #eoc #========================================================= #========================================================= #helpful mixin which provides lazy-loading of different backends #to be used for calc_checksum #========================================================= class BackendMixin(object): #NOTE: subclass must provide: # * attr 'backends' containing list of known backends (top priority backend first) # * attr '_has_backend_xxx' for each backend 'xxx', indicating if backend is available on system # * attr '_calc_checksum_xxx' for each backend 'xxx', containing calc_checksum implementation using that backend _backend = None @classmethod def get_backend(cls): "return name of active backend" return cls._backend or cls.set_backend() @classmethod def has_backend(cls, name): "check if specified class can be loaded" return getattr(cls, "_has_backend_" + name) @classmethod def set_backend(cls, name=None): "change class to use specified backend" if not name or name == "default": if not name: name = cls._backend if name: return name for name in cls.backends: if cls.has_backend(name): cls.calc_checksum = getattr(cls, "_calc_checksum_" + name) cls._backend = name return name raise EnvironmentError, "no %s backends available" % (cls.name,) else: ##if name not in cls.backends: ## raise ValueError, "unknown %s backend: %r" % (cls.name, name) if not cls.has_backend(name): raise ValueError, "%s backend not available: %r" % (cls.name, name) cls.calc_checksum = getattr(cls, "_calc_checksum_" + name) cls._backend = name return name def calc_checksum(self, secret): "stub for calc_checksum(), default backend will be selected first time stub is called" #backend not loaded - run detection and call replacement assert not self._backend, "set_backend() failed to replace lazy loader" self.set_backend() assert self._backend, "set_backend() failed to load a default backend" return self.calc_checksum(secret) class BackendExtHash(BackendMixin, ExtHash): pass class BackendStaticHash(BackendMixin, StaticHash): pass #========================================================= # eof #=========================================================