diff options
| author | Eli Collins <elic@assurancetechnologies.com> | 2011-12-28 12:42:06 -0500 |
|---|---|---|
| committer | Eli Collins <elic@assurancetechnologies.com> | 2011-12-28 12:42:06 -0500 |
| commit | 2c443dba95ecd986838dc315a3e590c7fe48695a (patch) | |
| tree | 3bf5165968fbdca16803333df396ab579630fea6 | |
| parent | 0114171bb9d38ab35ac6e754059839fd58ffffc8 (diff) | |
| parent | 73aefa5cdca69e1daf4297d8176d4a99434d33a2 (diff) | |
| download | passlib-2c443dba95ecd986838dc315a3e590c7fe48695a.tar.gz | |
Merge from default
| -rw-r--r-- | CHANGES | 5 | ||||
| -rw-r--r-- | admin/benchmarks.py | 158 | ||||
| -rw-r--r-- | docs/lib/passlib.utils.rst | 6 | ||||
| -rw-r--r-- | passlib.komodoproject | 1 | ||||
| -rw-r--r-- | passlib/context.py | 1371 | ||||
| -rw-r--r-- | passlib/handlers/bcrypt.py | 2 | ||||
| -rw-r--r-- | passlib/registry.py | 37 | ||||
| -rw-r--r-- | passlib/tests/test_context.py | 321 | ||||
| -rw-r--r-- | passlib/tests/test_utils.py | 86 | ||||
| -rw-r--r-- | passlib/tests/test_utils_handlers.py | 35 | ||||
| -rw-r--r-- | passlib/tests/utils.py | 13 | ||||
| -rw-r--r-- | passlib/utils/__init__.py | 153 | ||||
| -rw-r--r-- | passlib/utils/handlers.py | 61 |
13 files changed, 1623 insertions, 626 deletions
@@ -10,6 +10,11 @@ Release History .. currentmodule:: passlib.context + * Internals of :class:`CryptPolicy` have been + re-written drastically. Should now be stricter (and more informative) + about invalid values, and common :class:`CryptContext` + operations should all have much shorter code-paths. + * Config parsing now done with :class:`SafeConfigParser`. :meth:`CryptPolicy.from_path` and :meth:`CryptPolicy.from_string` diff --git a/admin/benchmarks.py b/admin/benchmarks.py new file mode 100644 index 0000000..e1503fd --- /dev/null +++ b/admin/benchmarks.py @@ -0,0 +1,158 @@ +"""admin/benchmarks - misc timing tests + +this is a *very* rough benchmark script hacked together when the context +parsing was being sped up. it could definitely be improved. +""" +#============================================================================= +# init app env +#============================================================================= +import os, sys +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.path.pardir)) + +#============================================================================= +# imports +#============================================================================= +# core +import logging; log = logging.getLogger(__name__) +from timeit import Timer +import warnings +# site +# pkg +try: + from passlib.utils import PasslibPolicyWarning +except ImportError: + PasslibPolicyWarning = None +from passlib.utils import handlers as uh +# local +__all__ = [ +] + +#============================================================================= +# utils +#============================================================================= + +class BlankHandler(uh.HasRounds, uh.HasSalt, uh.GenericHandler): + + setting_kwds = ("rounds", "salt", "salt_size") + name = "blank" + ident = u"$b$" + + checksum_size = 1 + min_salt_size = max_salt_size = 1 + salt_chars = u"a" + + min_rounds = 1000 + max_rounds = 3000 + default_rounds = 2000 + + @classmethod + def from_string(cls, hash): + r,s,c = uh.parse_mc3(hash, cls.ident, cls.name) + r = int(r) + return cls(rounds=r, salt=s, checksum=c, strict=bool(c)) + + def to_string(self): + return uh.render_mc3(self.ident, self.rounds, self.salt, self.checksum) + + def calc_checksum(self, password): + return unicode(password[0:1]) + +class AnotherHandler(BlankHandler): + name = "another" + ident = u"$a$" + +#============================================================================= +# crypt context tests +#============================================================================= +def setup_policy(): + import os + from passlib.context import _load_default_policy, CryptPolicy, \ + __file__ as mpath + cpath = os.path.abspath(os.path.join(os.path.dirname(mpath), "default.cfg")) + + def test_policy_creation(): + with file(cpath, "rb") as fh: + policy1 = CryptPolicy.from_string(fh.read()) + yield test_policy_creation + + default = _load_default_policy() + def test_policy_composition(): + policy2 = CryptPolicy( + policy=default, + schemes = [ "sha512_crypt", "sha256_crypt", "md5_crypt", + "des_crypt", "unix_fallback" ], + deprecated = [ "des_crypt" ], + ) + yield test_policy_composition + +def setup_context(): + from passlib.context import CryptContext + + def test_context_init(): + return CryptContext( + schemes=[BlankHandler, AnotherHandler], + default="another", + blank__min_rounds=1500, + blank__max_rounds=2500, + another__vary_rounds=100, + ) + yield test_context_init + + ctx = test_context_init() + secret = u"secret" + other = u"other" +# if PasslibPolicyWarning: +# warnings.filterwarnings("ignore", category=PasslibPolicyWarning) + def test_context_calls(): + hash = ctx.encrypt(secret, rounds=2001) + ctx.verify(secret, hash) + ctx.verify_and_update(secret, hash) + ctx.verify_and_update(other, hash) + yield test_context_calls + +#============================================================================= +# main +#============================================================================= +def pptime(secs): + precision = 3 + usec = int(secs * 1e6) + if usec < 1000: + return "%.*g usec" % (precision, usec) + msec = usec / 1000 + if msec < 1000: + return "%.*g msec" % (precision, msec) + sec = msec / 1000 + return "%.*g sec" % (precision, sec) + +def main(*args): + names = args + source = globals() + for key in sorted(source): + if not key.startswith("setup_"): + continue + sname = key[6:] + setup = source[key] + for test in setup(): + name = test.__name__ + if name.startswith("test_"): + name = name[5:] + if names and name not in names: + continue + timer = Timer(test) + number = 1 + while True: + t = timer.timeit(number) + if t > .2: + break + number *= 10 + repeat = 3 + best = min(timer.repeat(repeat, number)) / number + print "%30s %s" % (name, pptime(best)) + +if __name__ == "__main__": + import sys + main(*sys.argv[1:]) + +#============================================================================= +# eof +#============================================================================= diff --git a/docs/lib/passlib.utils.rst b/docs/lib/passlib.utils.rst index 0379e01..580c830 100644 --- a/docs/lib/passlib.utils.rst +++ b/docs/lib/passlib.utils.rst @@ -36,14 +36,12 @@ Constants .. autoexception:: MissingBackendError +.. autoexception:: PasslibPolicyWarning + Decorators ========== .. autofunction:: classproperty -.. - String Manipulation - .. autofunction:: splitcomma - Bytes Manipulation ================== diff --git a/passlib.komodoproject b/passlib.komodoproject index b579f50..64e57e5 100644 --- a/passlib.komodoproject +++ b/passlib.komodoproject @@ -6,5 +6,6 @@ <string id="import_exclude_matches">__pycache__;*$py.class;*.egg-info;cover;_build;dist;build;.*;*.*~;*.bak;*.tmp;CVS;.#*;*.pyo;*.pyc;.svn;*%*;tmp*.html;.DS_Store</string> <string id="import_include_matches"></string> <boolean id="import_live">1</boolean> + <string id="spellcheckLangID">en-US</string> </preference-set> </project> diff --git a/passlib/context.py b/passlib/context.py index 02b615c..f205905 100644 --- a/passlib/context.py +++ b/passlib/context.py @@ -7,10 +7,18 @@ from __future__ import with_statement import inspect import re import hashlib -from math import log as logb +from math import log as logb, ceil import logging; log = logging.getLogger(__name__) +##import sys +##if sys.platform == "win32": +## # On Windows, the best timer is time.clock() +## from time import clock as timer +##else: +## # On most other platforms the best timer is time.time() +## from time import time as timer import time import os +import re from warnings import warn #site try: @@ -19,9 +27,10 @@ except ImportError: #not available eg: under GAE resource_string = None #libs -from passlib.registry import get_crypt_handler, _unload_handler_name -from passlib.utils import to_bytes, to_unicode, Undef, \ - is_crypt_handler, splitcomma, rng +from passlib.registry import get_crypt_handler, _validate_handler_name +from passlib.utils import to_bytes, to_unicode, bytes, Undef, \ + is_crypt_handler, rng, \ + PasslibPolicyWarning from passlib.utils.compat import is_mapping, iteritems, int_types, \ PY3, PY_MIN_32, unicode, bytes from passlib.utils.compat.aliases import SafeConfigParser, StringIO, BytesIO @@ -36,81 +45,18 @@ __all__ = [ #crypt policy #========================================================= -#-------------------------------------------------------- -#constants controlling parsing of special kwds -#-------------------------------------------------------- - -#: CryptContext kwds which aren't allowed to have category specifiers -_forbidden_category_context_options = frozenset([ "schemes", ]) - #NOTE: forbidding 'schemes' because it would really complicate the behavior - # of CryptContext.identify & CryptContext.lookup. - # most useful behaviors here can be had by overriding deprecated - # and default, anyways. - +# NOTE: doing this for security purposes, why would you ever want a fixed salt? #: hash settings which aren't allowed to be set via policy _forbidden_hash_options = frozenset([ "salt" ]) - #NOTE: doing this for security purposes, why would you ever want a fixed salt? -#: CryptContext kwds which should be parsed into comma separated list of strings -_context_comma_options = frozenset([ "schemes", "deprecated" ]) - -#-------------------------------------------------------- -#parsing helpers -#-------------------------------------------------------- -def _parse_policy_key(key): - "helper to normalize & parse policy keys; returns ``(category, name, option)``" - orig = key - if '.' not in key and '__' in key: #lets user specifiy programmatically (since python doesn't allow '.') - key = key.replace("__", ".") - parts = key.split(".") - if len(parts) == 1: - cat = None - name = "context" - opt, = parts - elif len(parts) == 2: - cat = None - name, opt = parts - elif len(parts) == 3: - cat, name, opt = parts - else: - raise KeyError("keys must have 0..2 separators: %r" % (orig,)) - if cat == "default": - cat = None - assert name - assert opt - return cat, name, opt - -def _parse_policy_value(cat, name, opt, value): - "helper to parse policy values" - #FIXME: kinda primitive to parse things this way :| - if name == "context": - if opt in _context_comma_options: - if isinstance(value, str): - return splitcomma(value) - elif opt == "min_verify_time": - return float(value) - return value - else: - #try to coerce everything to int - try: - return int(value) - except ValueError: - return value - -def parse_policy_items(source): - "helper to parse CryptPolicy options" - if is_mapping(source): - source = iteritems(source) - for key, value in source: - cat, name, opt = _parse_policy_key(key) - if name == "context": - if cat and opt in _forbidden_category_context_options: - raise KeyError("%r context option is not allowed per-category" % (opt,)) - else: - if opt in _forbidden_hash_options: - raise KeyError("%r handler option is not allowed to be set via a policy object" % (opt,)) - value = _parse_policy_value(cat, name, opt, value) - yield cat, name, opt, value +def _splitcomma(source): + "split comma-separated string into list of strings" + source = source.strip() + if source.endswith(","): + source = source[:-1] + if not source: + return [] + return [ elem.strip() for elem in source.split(",") ] #-------------------------------------------------------- #policy class proper @@ -223,8 +169,7 @@ class CryptPolicy(object): p.read_file(stream, filename or "<???>") else: p.readfp(stream, filename or "<???>") - items = p.items(section) - return cls(**dict(items)) + return cls(dict(p.items(section))) @classmethod def from_source(cls, source): @@ -239,20 +184,24 @@ class CryptPolicy(object): :returns: new CryptPolicy instance. """ - if isinstance(source, cls): - #NOTE: can just return source unchanged, - #since we're treating CryptPolicy objects as read-only + if isinstance(source, CryptPolicy): + # NOTE: can just return source unchanged, + # since we're treating CryptPolicy objects as read-only return source elif isinstance(source, dict): - return cls(**source) + return cls(source) elif isinstance(source, (bytes,unicode)): - #FIXME: this autodetection makes me uncomfortable... - if any(c in source for c in "\n\r\t") or not source.strip(" \t./\;:"): #none of these chars should be in filepaths, but should be in config string + # FIXME: this autodetection makes me uncomfortable... + # it assumes none of these chars should be in filepaths, + # but should be in config string, in order to distinguish them. + if any(c in source for c in "\n\r\t") or \ + not source.strip(" \t./\;:"): return cls.from_string(source) - else: #other strings should be filepath + # other strings should be filepath + else: return cls.from_path(source) else: raise TypeError("source must be CryptPolicy, dict, config string, or file path: %r" % (type(source),)) @@ -270,28 +219,30 @@ class CryptPolicy(object): :returns: new CryptPolicy instance """ - #check for no sources - should we return blank policy in that case? + # check for no sources - should we return blank policy in that case? if len(sources) == 0: - #XXX: er, would returning an empty policy be the right thing here? + # XXX: er, would returning an empty policy be the right thing here? raise ValueError("no sources specified") - #check if only one source + # check if only one source if len(sources) == 1: return cls.from_source(sources[0]) - #else, build up list of kwds by parsing each source - kwds = {} + # else create policy from first source, update options, and rebuild. + result = _UncompiledCryptPolicy() + target = result._kwds for source in sources: - policy = cls.from_source(source) - kwds.update(policy.iter_config(resolve=True)) + policy = _UncompiledCryptPolicy.from_source(source) + target.update(policy._kwds) #build new policy - return cls(**kwds) + result._force_compile() + return result def replace(self, *args, **kwds): """return copy of policy, with specified options replaced by new values. - this is essentially a convience wrapper around :meth:`from_sources`, + this is essentially a convience record around :meth:`from_sources`, except that it always inserts the current policy as the first element in the list; this allows easily making minor changes from an existing policy object. @@ -311,132 +262,268 @@ class CryptPolicy(object): #========================================================= #instance attrs #========================================================= - #NOTE: all category dictionaries below will have a minimum of 'None' as a key + #: dict of (category,scheme,key) -> value, representing the original + # raw keywords passed into constructor. the rest of the policy's data + # structures are derived from this attribute via _compile() + _kwds = None - #:list of all handlers, in order they will be checked when identifying (reverse of order specified) - _handlers = None #list of password hash handlers instances. + #: list of user categories in sorted order; first entry is always `None` + _categories = None - #:dict mapping category -> default handler for that category - _default = None + #: list of all schemes specified by `context.schemes` + _schemes = None - #:dict mapping category -> set of handler names which are deprecated for that category - _deprecated = None + #: list of all handlers specified by `context.schemes` + _handlers = None - #:dict mapping category -> min verify time - _min_verify_time = None - - #:dict mapping category -> dict mapping hash name -> dict of options for that hash - # if a category is specified, particular hash names will be mapped ONLY if that category - # has options which differ from the default options. - _options = None + #: double-nested dict mapping key -> category -> normalized value. + _context_options = None - #:dict mapping (handler name, category) -> dict derived from options. - # this is used to cache results of the get_option() method - _cache = None + #: triply-nested dict mapping scheme -> category -> key -> normalized value. + _scheme_options = None #========================================================= - #init + # init #========================================================= - def __init__(self, **kwds): - self._from_dict(kwds) + def __init__(self, *args, **kwds): + if args: + if len(args) != 1: + raise TypeError("only one positional argument accepted") + if kwds: + raise TypeError("cannot specify positional arg and kwds") + kwds = args[0] + # XXX: type check, and accept strings for from_source ? + parse = self._parse_option_key + self._kwds = dict((parse(key), value) for key, value in + kwds.iteritems()) + self._compile() + + @staticmethod + def _parse_option_key(ckey): + "helper to expand policy keys into ``(category, name, option)`` tuple" + ##if isinstance(ckey, tuple): + ## assert len(ckey) == 3, "keys must have 3 parts: %r" % (ckey,) + ## return ckey + parts = ckey.split("." if "." in ckey else "__") + count = len(parts) + if count == 1: + return None, None, parts[0] + elif count == 2: + scheme, key = parts + if scheme == "context": + scheme = None + return None, scheme, key + elif count == 3: + cat, scheme, key = parts + if cat == "default": + cat = None + if scheme == "context": + scheme = None + return cat, scheme, key + else: + raise TypeError("keys must have less than 3 separators: %r" % + (ckey,)) #========================================================= - #internal init helpers + # compile internal data structures #========================================================= - def _from_dict(self, kwds): - "configure policy from constructor keywords" - # - #init cache & options - # - context_options = {} - options = self._options = {None:{"context":context_options}} - self._cache = {} - - # - #normalize & sort keywords - # - for cat, name, opt, value in parse_policy_items(kwds): - copts = options.get(cat) - if copts is None: - copts = options[cat] = {} - config = copts.get(name) - if config is None: - copts[name] = {opt:value} + def _compile(self): + "compile internal caches from :attr:`_kwds`" + source = self._kwds + + # build list of handlers & schemes + handlers = self._handlers = [] + schemes = self._schemes = [] + data = source.get((None,None,"schemes")) + if isinstance(data, str): + data = _splitcomma(data) + if data: + for elem in data: + #resolve & validate handler + if hasattr(elem, "name"): + handler = elem + scheme = handler.name + _validate_handler_name(scheme) + else: + handler = get_crypt_handler(elem) + scheme = handler.name + + #check scheme hasn't been re-used + if scheme in schemes: + raise KeyError("multiple handlers with same name: %r" % + (scheme,)) + + #add to handler list + handlers.append(handler) + schemes.append(scheme) + + # run through all other values in source, normalize them, and store in + # scheme/context option dictionaries. + scheme_options = self._scheme_options = {} + context_options = self._context_options = {} + norm_scheme_option = self._normalize_scheme_option + norm_context_option = self._normalize_context_option + cats = set([None]) + add_cat = cats.add + for (cat, scheme, key), value in source.iteritems(): + add_cat(cat) + if scheme: + value = norm_scheme_option(key, value) + if scheme in scheme_options: + config = scheme_options[scheme] + if cat in config: + config[cat][key] = value + else: + config[cat] = {key: value} + else: + scheme_options[scheme] = {cat: {key: value}} + elif key == "schemes": + if cat: + raise KeyError("'schemes' context option is not allowed " + "per category") + continue else: - config[opt] = value + value = norm_context_option(key, value) + if key in context_options: + context_options[key][cat] = value + else: + context_options[key] = {cat: value} + + # store list of categories + self._categories = sorted(cats) + + @staticmethod + def _normalize_scheme_option(key, value): + # some hash options can't be specified in the policy, e.g. 'salt' + if key in _forbidden_hash_options: + raise KeyError("Passlib does not permit %r handler option " + "to be set via a policy object" % (key,)) + + # for hash options, try to coerce everything to an int, + # since most things are (e.g. the `*_rounds` options). + elif isinstance(value, str): + try: + value = int(value) + except ValueError: + pass + return value - # - #parse list of schemes, and resolve to handlers. - # - schemes = context_options.get("schemes") or [] - handlers = self._handlers = [] - handler_names = set() - for scheme in schemes: - #resolve & validate handler - if is_crypt_handler(scheme): - handler = scheme - else: - handler = get_crypt_handler(scheme) - name = handler.name - if not name: - raise TypeError("handler lacks name: %r" % (handler,)) + def _normalize_context_option(self, key, value): + "validate & normalize option value" + if key == "default": + if hasattr(value, "name"): + value = value.name + schemes = self._schemes + if schemes and value not in schemes: + raise KeyError("default scheme not found in policy") + + elif key == "deprecated": + if isinstance(value, str): + value = _splitcomma(value) + schemes = self._schemes + if schemes: + # if schemes are defined, do quick validation first. + for scheme in value: + if scheme not in schemes: + raise KeyError("deprecated scheme not found " + "in policy: %r" % (scheme,)) + + elif key == "min_verify_time": + value = float(value) + if value < 0: + raise ValueError("'min_verify_time' must be >= 0") - #check name hasn't been re-used - if name in handler_names: - #XXX: should this just be a warning ? - raise KeyError("multiple handlers with same name: %r" % (name,)) + else: + raise KeyError("unknown context keyword: %r" % (key,)) - #add to handler list - handlers.append(handler) - handler_names.add(name) + return value - # - #build _deprecated & _default maps - # - dmap = self._deprecated = {} - fmap = self._default = {} - mvmap = self._min_verify_time = {} - for cat, config in iteritems(options): - kwds = config.pop("context", None) - if not kwds: - continue + #========================================================= + # private helpers for reading options + #========================================================= + def _get_option(self, scheme, category, key, default=None): + "get specific option value, without inheritance" + try: + if scheme: + return self._scheme_options[scheme][category][key] + else: + return self._context_options[key][category] + except KeyError: + return default - #list of deprecated schemes - deps = kwds.get("deprecated") or [] - if deps: - if handlers: - for scheme in deps: - if scheme not in handler_names: - raise KeyError("known scheme in deprecated list: %r" % (scheme,)) - dmap[cat] = frozenset(deps) - - #default scheme - fb = kwds.get("default") - if fb: - if handlers: - if hasattr(fb, "name"): - fb = fb.name - if fb not in handler_names: - raise KeyError("unknown scheme set as default: %r" % (fb,)) - fmap[cat] = self.get_handler(fb, required=True) - else: - fmap[cat] = fb - - #min verify time - value = kwds.get("min_verify_time") - if value: - mvmap[cat] = value - #XXX: error or warning if unknown key found in kwds? - #NOTE: for dmap/fmap/mvmap - - # if no cat=None value is specified, each has it's own defaults, - # (handlers[0] for fmap, set() for dmap, 0 for mvmap) - # but we don't store those in dict since it would complicate policy merge operation + def _get_handler_options(self, scheme, category): + "return composite dict of handler options for given scheme + category" + scheme_options = self._scheme_options + has_cat_options = False + + # start with options common to all schemes + common_kwds = scheme_options.get("all") + if common_kwds is None: + kwds = {} + else: + # start with global options + tmp = common_kwds.get(None) + kwds = tmp.copy() if tmp is not None else {} + + # add category options + if category: + tmp = common_kwds.get(category) + if tmp is not None: + kwds.update(tmp) + has_cat_options = True + + # add scheme-specific options + scheme_kwds = scheme_options.get(scheme) + if scheme_kwds is not None: + # add global options + tmp = scheme_kwds.get(None) + if tmp is not None: + kwds.update(tmp) + + # add category options + if category: + tmp = scheme_kwds.get(category) + if tmp is not None: + kwds.update(tmp) + has_cat_options = True + + # add context options + context_options = self._context_options + if context_options is not None: + # add deprecated flag + dep_map = context_options.get("deprecated") + if dep_map: + deplist = dep_map.get(None) + dep = (deplist is not None and scheme in deplist) + if category: + deplist = dep_map.get(category) + if deplist is not None: + value = (scheme in deplist) + if value != dep: + dep = value + has_cat_options = True + if dep: + kwds['deprecated'] = True + + # add min_verify_time flag + mvt_map = context_options.get("min_verify_time") + if mvt_map: + mvt = mvt_map.get(None) + if category: + value = mvt_map.get(category) + if value is not None and value != mvt: + mvt = value + has_cat_options = True + if mvt: + kwds['min_verify_time'] = mvt + + return kwds, has_cat_options #========================================================= - #public interface (used by CryptContext) + # public interface for examining options #========================================================= def has_schemes(self): - "check if policy supported *any* schemes; returns True/False" + "check if policy supports *any* schemes; returns True/False" return len(self._handlers) > 0 def iter_handlers(self): @@ -448,7 +535,7 @@ class CryptPolicy(object): if resolve: return list(self._handlers) else: - return [h.name for h in self._handlers] + return list(self._schemes) def get_handler(self, name=None, category=None, required=False): """given the name of a scheme, return handler which manages it. @@ -462,24 +549,24 @@ class CryptPolicy(object): :returns: handler attached to specified name or None """ - if name: - for handler in self._handlers: - if handler.name == name: - return handler - else: - fmap = self._default - if category in fmap: - return fmap[category] - elif category and None in fmap: - return fmap[None] - else: - handlers = self._handlers - if handlers: - return handlers[0] - raise KeyError("no crypt algorithms supported") + if name is None: + name = self._get_option(None, category, "default") + if not name and category: + name = self._get_option(None, None, "default") + if not name and self._handlers: + return self._handlers[0] + if not name: + if required: + raise KeyError("no crypt algorithms found in policy") + else: + return None + for handler in self._handlers: + if handler.name == name: + return handler if required: - raise KeyError("no crypt algorithm by that name: %r" % (name,)) - return None + raise KeyError("crypt algorithm not found in policy: %r" % (name,)) + else: + return None def get_options(self, name, category=None): """return dict of options for specified scheme @@ -489,141 +576,102 @@ class CryptPolicy(object): :returns: dict of options for CryptContext internals which are relevant to this name/category combination. """ + # XXX: deprecate / enhance this function ? if hasattr(name, "name"): name = name.name - - cache = self._cache - key = (name, category) - try: - return cache[key] - except KeyError: - pass - - #TODO: pre-calculate or at least cache some of this. - options = self._options - - #start with default values - kwds = options[None].get("all") - if kwds is None: - kwds = {} - else: - kwds = kwds.copy() - - #mix in category default values - if category and category in options: - tmp = options[category].get("all") - if tmp: - kwds.update(tmp) - - #mix in hash-specific options - tmp = options[None].get(name) - if tmp: - kwds.update(tmp) - - #mix in category hash-specific options - if category and category in options: - tmp = options[category].get(name) - if tmp: - kwds.update(tmp) - - cache[key] = kwds - return kwds + return self._get_handler_options(name, category)[0] def handler_is_deprecated(self, name, category=None): "check if scheme is marked as deprecated according to this policy; returns True/False" + # XXX: deprecate this function ? if hasattr(name, "name"): name = name.name - dmap = self._deprecated - if category in dmap: - return name in dmap[category] - elif category and None in dmap: - return name in dmap[None] - else: - return False + kwds = self._get_handler_options(name, category)[0] + return bool(kwds.get("deprecated")) def get_min_verify_time(self, category=None): - "return minimal time that verify() should take, according to this policy" - mvmap = self._min_verify_time - if category in mvmap: - return mvmap[category] - elif category and None in mvmap: - return mvmap[None] - else: - return 0 + # XXX: deprecate this function ? + kwds = self._get_handler_options("all", category)[0] + return kwds.get("min_verify_time") or 0 #========================================================= - #serialization + # serialization #========================================================= + + ##def __iter__(self): + ## return self.iter_config(resolve=True) + def iter_config(self, ini=False, resolve=False): """iterate through key/value pairs of policy configuration :param ini: If ``True``, returns data formatted for insertion into INI file. Keys use ``.`` separator instead of ``__``; - list of handlers returned as comma-separated strings. + lists of handlers are returned as comma-separated strings. :param resolve: If ``True``, returns handler objects instead of handler names where appropriate. Ignored if ``ini=True``. :returns: - iterator which yeilds (key,value) pairs. + iterator which yields (key,value) pairs. """ # #prepare formatting functions # - if ini: - fmt1 = "%s.%s.%s" - fmt2 = "%s.%s" - def encode_handler(h): - return h.name - def encode_hlist(hl): - return ", ".join(h.name for h in hl) - def encode_nlist(hl): - return ", ".join(name for name in hl) - else: - fmt1 = "%s__%s__%s" - fmt2 = "%s__%s" - encode_nlist = list - if resolve: - def encode_handler(h): - return h - encode_hlist = list - else: - def encode_handler(h): - return h.name - def encode_hlist(hl): - return [ h.name for h in hl ] + sep = "." if ini else "__" - def format_key(cat, name, opt): + def format_key(cat, name, key): if cat: - return fmt1 % (cat, name or "context", opt) + return sep.join([cat, name or "context", key]) if name: - return fmt2 % (name, opt) - return opt + return sep.join([name, key]) + return key + + def encode_list(hl): + if ini: + return ", ".join(hl) + else: + return list(hl) # #run through contents of internal configuration # - value = self._handlers - if value: - yield format_key(None, None, "schemes"), encode_hlist(value) - - for cat, value in iteritems(self._deprecated): - yield format_key(cat, None, "deprecated"), encode_nlist(value) - for cat, value in iteritems(self._default): - yield format_key(cat, None, "default"), encode_handler(value) - - for cat, value in iteritems(self._min_verify_time): - yield format_key(cat, None, "min_verify_time"), value - - for cat, copts in iteritems(self._options): - for name in sorted(copts): - config = copts[name] - for opt in sorted(config): - value = config[opt] - yield format_key(cat, name, opt), value + # write list of handlers at start + if (None,None,"schemes") in self._kwds: + if resolve and not ini: + value = self._handlers + else: + value = self._schemes + yield format_key(None, None, "schemes"), encode_list(value) + + # then per-category elements + scheme_items = sorted(self._scheme_options.iteritems()) + get_option = self._get_option + for cat in self._categories: + + # write deprecated list (if any) + value = get_option(None, cat, "deprecated") + if value is not None: + yield format_key(cat, None, "deprecated"), encode_list(value) + + # write default declaration (if any) + value = get_option(None, cat, "default") + if value is not None: + yield format_key(cat, None, "default"), value + + # write mvt (if any) + value = get_option(None, cat, "min_verify_time") + if value is not None: + yield format_key(cat, None, "min_verify_time"), value + + # write configs for all schemes + for scheme, config in scheme_items: + if cat in config: + kwds = config[cat] + for key in sorted(kwds): + yield format_key(cat, scheme, key), kwds[key] def to_dict(self, resolve=False): "return policy as dictionary of keywords" @@ -632,7 +680,7 @@ class CryptPolicy(object): def _escape_ini_pair(self, k, v): if isinstance(v, str): v = v.replace("%", "%%") #escape any percent signs. - elif isinstance(v, int_types): + elif isinstance(v, (int, long, float)): v = str(v) return k,v @@ -678,9 +726,23 @@ class CryptPolicy(object): #eoc #========================================================= -#========================================================= +class _UncompiledCryptPolicy(CryptPolicy): + """helper class which parses options but doesn't compile them, + used by CryptPolicy.from_sources() to efficiently merge policy objects. + """ + + def _compile(self): + "convert to actual policy" + pass + + def _force_compile(self): + "convert to real policy and compile" + self.__class__ = CryptPolicy + self._compile() + +#--------------------------------------------------------- #load default policy from default.cfg -#========================================================= +#--------------------------------------------------------- def _load_default_policy(): "helper to try to load default policy from file" #if pkg_resources available, try to read out of egg (common case) @@ -704,7 +766,336 @@ def _load_default_policy(): default_policy = _load_default_policy() #========================================================= -# +# helpers for CryptContext +#========================================================= +class _CryptRecord(object): + """wraps a handler and automatically applies various options. + + this is a helper used internally by CryptContext in order to reduce the + amount of work that needs to be done by CryptContext.verify(). + this class takes in all the options for a particular (scheme, category) + combination, and attempts to provide as short a code-path as possible for + the particular configuration. + """ + + #================================================================ + # instance attrs + #================================================================ + + # informational attrs + handler = None # handler instance this is wrapping + category = None # user category this applies to + + # rounds management + _has_rounds = False # if handler has variable cost parameter + _has_rounds_bounds = False # if min_rounds / max_rounds set + _min_rounds = None #: minimum rounds allowed by policy, or None + _max_rounds = None #: maximum rounds allowed by policy, or None + + # encrypt()/genconfig() attrs + _settings = None # subset of options to be used as encrypt() defaults. + + # verify() attrs + _min_verify_time = None + + # hash_needs_update() attrs + _has_rounds_introspection = False + + # cloned from handler + identify = None + genhash = None + + #================================================================ + # init + #================================================================ + def __init__(self, handler, category=None, deprecated=False, + min_rounds=None, max_rounds=None, default_rounds=None, + vary_rounds=None, min_verify_time=None, + **settings): + self.handler = handler + self.category = category + self._compile_rounds(min_rounds, max_rounds, default_rounds, + vary_rounds) + self._compile_encrypt(settings) + self._compile_verify(min_verify_time) + self._compile_deprecation(deprecated) + + # these aren't modified by the record, so just copy them directly + self.identify = handler.identify + self.genhash = handler.genhash + + @property + def scheme(self): + return self.handler.name + + @property + def _ident(self): + "string used to identify record in error messages" + handler = self.handler + category = self.category + if category: + return "%s %s policy" % (handler.name, category) + else: + return "%s policy" % (handler.name,) + + #================================================================ + # rounds generation & limits - used by encrypt & deprecation code + #================================================================ + def _compile_rounds(self, mn, mx, df, vr): + "parse options and compile efficient generate_rounds function" + handler = self.handler + if 'rounds' not in handler.setting_kwds: + return + hmn = getattr(handler, "min_rounds", None) + hmx = getattr(handler, "max_rounds", None) + + def hcheck(value, name): + "issue warnings if value outside of handler limits" + if hmn is not None and value < hmn: + warn("%s: %s value is below handler minimum %d: %d" % + (self._ident, name, hmn, value), PasslibPolicyWarning) + if hmx is not None and value > hmx: + warn("%s: %s value is above handler maximum %d: %d" % + (self._ident, name, hmx, value), PasslibPolicyWarning) + + def clip(value): + "clip value to policy & handler limits" + if mn is not None and value < mn: + value = mn + if hmn is not None and value < hmn: + value = hmn + if mx is not None and value > mx: + value = mx + if hmx is not None and value > hmx: + value = hmx + return value + + #---------------------------------------------------- + # validate inputs + #---------------------------------------------------- + if mn is not None: + if mn < 0: + raise ValueError("%s: min_rounds must be >= 0" % self._ident) + hcheck(mn, "min_rounds") + + if mx is not None: + if mn is not None and mx < mn: + raise ValueError("%s: max_rounds must be " + ">= min_rounds" % self._ident) + elif mx < 0: + raise ValueError("%s: max_rounds must be >= 0" % self._ident) + hcheck(mx, "max_rounds") + + if vr is not None: + if isinstance(vr, str): + assert vr.endswith("%") + vr = float(vr.rstrip("%")) + if vr < 0: + raise ValueError("%s: vary_rounds must be >= '0%%'" % + self._ident) + elif vr > 100: + raise ValueError("%s: vary_rounds must be <= '100%%'" % + self._ident) + vr_is_pct = True + else: + assert isinstance(vr, int) + if vr < 0: + raise ValueError("%s: vary_rounds must be >= 0" % + self._ident) + vr_is_pct = False + + if df is None: + # fallback to handler's default if available + if vr or mx or mn: + df = getattr(handler, "default_rounds", None) or mx or mn + else: + if mn is not None and df < mn: + raise ValueError("%s: default_rounds must be " + ">= min_rounds" % self._ident) + if mx is not None and df > mx: + raise ValueError("%s: default_rounds must be " + "<= max_rounds" % self._ident) + hcheck(df, "default_rounds") + + #---------------------------------------------------- + # set policy limits + #---------------------------------------------------- + self._has_rounds_bounds = (mn is not None) or (mx is not None) + self._min_rounds = mn + self._max_rounds = mx + + #---------------------------------------------------- + # setup rounds generation function + #---------------------------------------------------- + if df is None: + self._generate_rounds = None + self._has_rounds = self._has_rounds_bounds + elif vr: + scale_value = lambda v,uf: v + if vr_is_pct: + scale = getattr(handler, "rounds_cost", "linear") + assert scale in ["log2", "linear"] + if scale == "log2": + df = 1<<df + def scale_value(v, uf): + if v <= 0: + return 0 + elif uf: + return int(logb(v,2)) + else: + return int(ceil(logb(v,2))) + vr = int(df*vr/100) + lower = clip(scale_value(df-vr,False)) + upper = clip(scale_value(df+vr,True)) + if lower == upper: + self._generate_rounds = lambda: upper + else: + assert lower < upper + self._generate_rounds = lambda: rng.randint(lower, upper) + self._has_rounds = True + else: + df = clip(df) + self._generate_rounds = lambda: df + self._has_rounds = True + + # filled in by _compile_rounds_settings() + _generate_rounds = None + + #================================================================ + # encrypt() / genconfig() + #================================================================ + def _compile_encrypt(self, settings): + handler = self.handler + skeys = handler.setting_kwds + self._settings = dict((k,v) for k,v in settings.iteritems() + if k in skeys) + + if not (self._settings or self._has_rounds): + # bypass prepare settings entirely. + self.genconfig = handler.genconfig + self.encrypt = handler.encrypt + + def genconfig(self, **kwds): + self._prepare_settings(kwds) + return self.handler.genconfig(**kwds) + + def encrypt(self, secret, **kwds): + self._prepare_settings(kwds) + return self.handler.encrypt(secret, **kwds) + + def _prepare_settings(self, kwds): + "normalize settings for handler according to context configuration" + #load in default values for any settings + settings = self._settings + for k in settings: + if k not in kwds: + kwds[k] = settings[k] + + #handle rounds + if self._has_rounds: + rounds = kwds.get("rounds") + if rounds is None: + gen = self._generate_rounds + if gen: + kwds['rounds'] = gen() + elif self._has_rounds_bounds: + # XXX: should this raise an error instead of warning ? + mn = self._min_rounds + if mn is not None and rounds < mn: + warn("%s requires rounds >= %d, increasing value from %d" % + (self._ident, mn, rounds), PasslibPolicyWarning) + rounds = mn + mx = self._max_rounds + if mx and rounds > mx: + warn("%s requires rounds <= %d, decreasing value from %d" % + (self._ident, mx, rounds), PasslibPolicyWarning) + rounds = mx + kwds['rounds'] = rounds + + #================================================================ + # verify() + #================================================================ + def _compile_verify(self, mvt): + if mvt: + assert mvt > 0, "CryptPolicy should catch this" + self._min_verify_time = mvt + else: + # no mvt wrapper needed, so just use handler.verify directly + self.verify = self.handler.verify + + def verify(self, secret, hash, **context): + "verify helper - adds min_verify_time delay" + mvt = self._min_verify_time + assert mvt + start = time.time() + ok = self.handler.verify(secret, hash, **context) + end = time.time() + delta = mvt + start - end + if delta > 0: + time.sleep(delta) + elif delta < 0: + #warn app they aren't being protected against timing attacks... + warn("CryptContext: verify exceeded min_verify_time: " + "scheme=%r min_verify_time=%r elapsed=%r" % + (self.scheme, mvt, end-start)) + return ok + + #================================================================ + # hash_needs_update() + #================================================================ + def _compile_deprecation(self, deprecated): + if deprecated: + self.hash_needs_update = lambda hash: True + return + + handler = self.handler + self._hash_needs_update = getattr(handler, "_hash_needs_update", None) + + # check if there are rounds, rounds limits, and if we can + # parse the rounds from the handler. if that's the case... + if self._has_rounds_bounds and hasattr(handler, "from_string"): + self._has_rounds_introspection = True + + def hash_needs_update(self, hash): + # NOTE: this is replaced by _compile_deprecation() if self.deprecated + + # XXX: could check if handler provides it's own helper, e.g. + # getattr(handler, "hash_needs_update", None), possibly instead of + # calling the default check below... + # + # NOTE: hacking this in for the sake of bcrypt & issue 25, + # will formalize (and possibly change) interface later. + hnu = self._hash_needs_update + if hnu and hnu(hash): + return True + + # if we can parse rounds parameter, check if it's w/in bounds. + if self._has_rounds_introspection: + hash_obj = self.handler.from_string(hash) + try: + rounds = hash_obj.rounds + except AttributeError: + # XXX: hash_obj should generally have rounds attr + # should a warning be raised here? + pass + else: + if rounds < self._min_rounds: + return True + mx = self._max_rounds + if mx and rounds > mx: + return True + + return False + + # filled in by init from handler._hash_needs_update + _hash_needs_update = None + + #================================================================ + # eoc + #================================================================ + +#========================================================= +# context classes #========================================================= class CryptContext(object): """Helper for encrypting passwords using different algorithms. @@ -755,13 +1146,17 @@ class CryptContext(object): #=================================================================== #instance attrs #=================================================================== - policy = None #policy object governing context + _policy = None # policy object governing context - access via :attr:`policy` + _records = None # map of (category,scheme) -> _CryptRecord instance + _record_lists = None # map of category -> records for category, in order #=================================================================== #init #=================================================================== def __init__(self, schemes=None, policy=default_policy, **kwds): - #XXX: add a name for the contexts, to help out repr? + # XXX: add a name for the contexts, to help out repr? + # XXX: add ability to make policy readonly for certain instances, + # eg the builtin passlib ones? if schemes: kwds['schemes'] = schemes if not policy: @@ -771,8 +1166,9 @@ class CryptContext(object): self.policy = policy def __repr__(self): - #XXX: *could* have proper repr(), but would have to render policy object options, and it'd be *really* long - names = [ handler.name for handler in self.policy.iter_handlers() ] + # XXX: *could* have proper repr(), but would have to render policy + # object options, and it'd be *really* long + names = [ handler.name for handler in self.policy._handlers ] return "<CryptContext %0xd schemes=%r>" % (id(self), names) #XXX: make an update() method that just updates policy? @@ -790,78 +1186,119 @@ class CryptContext(object): """ return CryptContext(policy=self.policy.replace(**kwds)) + #XXX: make an update() method that just updates policy? + ##def update(self, **kwds): + ## self.policy = self.policy.replace(**kwds) + #=================================================================== - #policy adaptation + # policy management #=================================================================== - def _prepare_rounds(self, handler, opts, settings): - "helper for prepare_default_settings" - mn = opts.get("min_rounds") - mx = opts.get("max_rounds") - rounds = settings.get("rounds") - if rounds is None: - df = opts.get("default_rounds") or mx or mn - if df is not None: - vr = opts.get("vary_rounds") - if vr: - if isinstance(vr, str): - rc = getattr(handler, "rounds_cost", "linear") - vr = int(vr.rstrip("%")) - #NOTE: deliberately strip >1 %, - #in case an interpolation-escaped %% - #makes it through to here. - assert 0 <= vr < 100 - if rc == "log2": - #let % variance scale the number of actual rounds, not the logarithmic value - df = 2**df - vr = int(df*vr/100) - lower = int(logb(df-vr,2)+.5) #err on the side of strength - round up - upper = int(logb(df+vr,2)) - else: - assert rc == "linear" - vr = int(df*vr/100) - lower = df-vr - upper = df+vr - else: - lower = df-vr - upper = df+vr - if lower < 1: - lower = 1 - if mn and lower < mn: - lower = mn - if mx and upper > mx: - upper = mx - if lower > upper: - #NOTE: this mainly happens when default_rounds>max_rounds, which shouldn't usually happen - rounds = upper - warn("vary default rounds: lower bound > upper bound, using upper bound (%d > %d)" % (lower, upper)) - else: - rounds = rng.randint(lower, upper) + + def _get_policy(self): + return self._policy + + def _set_policy(self, value): + if not isinstance(value, CryptPolicy): + raise TypeError("value must be a CryptPolicy instance") + if value is not self._policy: + self._policy = value + self._compile() + + policy = property(_get_policy, _set_policy) + + #------------------------------------------------------------------ + # compile policy information into _CryptRecord instances + #------------------------------------------------------------------ + def _compile(self): + "update context object internals based on new policy instance" + policy = self._policy + records = self._records = {} + self._record_lists = {} + handlers = policy._handlers + if not handlers: + return + get_option = policy._get_option + get_handler_options = policy._get_handler_options + schemes = policy._schemes + default_scheme = get_option(None, None, "default") or schemes[0] + for cat in policy._categories: + # build record for all schemes, re-using record from default + # category if there aren't any category-specific options. + for handler in handlers: + scheme = handler.name + kwds, has_cat_options = get_handler_options(scheme, cat) + if cat and not has_cat_options: + records[scheme, cat] = records[scheme, None] else: - rounds = df - if rounds is not None: - if mx and rounds > mx: - rounds = mx - if mn and rounds < mn: #give mn predence if mn > mx - rounds = mn - settings['rounds'] = rounds - - def _prepare_settings(self, handler, category=None, **settings): - "normalize settings for handler according to context configuration" - opts = self.policy.get_options(handler, category) - if not opts: - return settings + records[scheme, cat] = _CryptRecord(handler, cat, **kwds) + # clone default scheme's record to None so we can resolve default + if cat: + scheme = get_option(None, cat, "default") or default_scheme + else: + scheme = default_scheme + records[None, cat] = records[scheme, cat] - #load in default values for any settings - for k in handler.setting_kwds: - if k not in settings and k in opts: - settings[k] = opts[k] + def _get_record(self, scheme, category=None, required=True): + "private helper used by CryptContext" + try: + return self._records[scheme, category] + except KeyError: + pass + if category: + # category not referenced in policy file. + # so populate cache from default category. + cache = self._records + try: + record = cache[scheme, None] + except KeyError: + pass + else: + cache[scheme, category] = record + return record + if not required: + return None + elif scheme: + raise KeyError("crypt algorithm not found in policy: %r" % + (scheme,)) + else: + assert not self._policy._handlers + raise KeyError("no crypt algorithms supported") - #handle rounds - if 'rounds' in handler.setting_kwds: - self._prepare_rounds(handler, opts, settings) + def _get_record_list(self, category=None): + "return list of records for category" + try: + return self._record_lists[category] + except KeyError: + # XXX: could optimize for categories not in policy. + get = self._get_record + value = self._record_lists[category] = [ + get(scheme, category) + for scheme in self._policy._schemes + ] + return value + + def _identify_record(self, hash, category=None, required=True): + "internal helper to identify appropriate _HandlerRecord" + records = self._get_record_list(category) + for record in records: + if record.identify(hash): + return record + if required: + if not records: + raise KeyError("no crypt algorithms supported") + raise ValueError("hash could not be identified") + else: + return None + + #=================================================================== + #password hash api proxy methods + #=================================================================== - #done - return settings + # NOTE: all the following methods do is look up the appropriate + # _CryptRecord for a given (scheme,category) combination, + # and then let the record object take care of the rest, + # since it will have optimized itself for the particular + # settings used within the policy by that (scheme,category). def hash_needs_update(self, hash, category=None): """check if hash is allowed by current policy, or if secret should be re-encrypted. @@ -879,43 +1316,9 @@ class CryptContext(object): :returns: True/False """ - handler = self.identify(hash, resolve=True, required=True) - policy = self.policy + # XXX: add scheme kwd for compatibility w/ other methods? + return self._identify_record(hash, category).hash_needs_update(hash) - #check if handler has been deprecated - if policy.handler_is_deprecated(handler, category): - return True - - #get options, and call compliance helper (check things such as rounds, etc) - opts = policy.get_options(handler, category) - - #XXX: could check if handler provides it's own helper, eg getattr(handler, "hash_needs_update", None), - #and call that instead of the following default behavior - if hasattr(handler, "_hash_needs_update"): - #NOTE: hacking this in for the sake of bcrypt & issue 25, - # will formalize (and possibly change) interface later. - if handler._hash_needs_update(hash, **opts): - return True - - if opts: - #check if we can parse hash to check it's rounds parameter - if ('min_rounds' in opts or 'max_rounds' in opts) and \ - 'rounds' in handler.setting_kwds and hasattr(handler, "from_string"): - info = handler.from_string(hash) - rounds = getattr(info, "rounds", None) #should generally work, but just in case - if rounds is not None: - min_rounds = opts.get("min_rounds") - if min_rounds is not None and rounds < min_rounds: - return True - max_rounds = opts.get("max_rounds") - if max_rounds is not None and rounds > max_rounds: - return True - - return False - - #=================================================================== - #password hash api proxy methods - #=================================================================== def genconfig(self, scheme=None, category=None, **settings): """Call genconfig() for specified handler @@ -927,9 +1330,7 @@ class CryptContext(object): directly is that this method will add in any policy-specific options relevant for the particular hash. """ - handler = self.policy.get_handler(scheme, category, required=True) - settings = self._prepare_settings(handler, category, **settings) - return handler.genconfig(**settings) + return self._get_record(scheme, category).genconfig(**settings) def genhash(self, secret, config, scheme=None, category=None, **context): """Call genhash() for specified handler. @@ -938,13 +1339,9 @@ class CryptContext(object): (using the default if none other is specified). See the :ref:`password-hash-api` for details. """ - #NOTE: this doesn't use category in any way, but accepts it for consistency - if scheme: - handler = self.policy.get_handler(scheme, required=True) - else: - handler = self.identify(config, resolve=True, required=True) #XXX: could insert normalization to preferred unicode encoding here - return handler.genhash(secret, config, **context) + return self._get_record(scheme, category).genhash(secret, config, + **context) def identify(self, hash, category=None, resolve=False, required=False): """Attempt to identify which algorithm hash belongs to w/in this context. @@ -963,23 +1360,17 @@ class CryptContext(object): The handler which first identifies the hash, or ``None`` if none of the algorithms identify the hash. """ - #NOTE: this doesn't use category in any way, but accepts it for consistency if hash is None: if required: - raise ValueError("no hash specified") + raise ValueError("no hash provided") return None - handler = None - for handler in self.policy.iter_handlers(): - if handler.identify(hash): - if resolve: - return handler - else: - return handler.name - if required: - if handler is None: - raise KeyError("no crypt algorithms supported") - raise ValueError("hash could not be identified") - return None + record = self._identify_record(hash, category, required) + if record is None: + return None + elif resolve: + return record.handler + else: + return record.scheme def encrypt(self, secret, scheme=None, category=None, **kwds): """encrypt secret, returning resulting hash. @@ -1000,10 +1391,8 @@ class CryptContext(object): :returns: The secret as encoded by the specified algorithm and options. """ - handler = self.policy.get_handler(scheme, category, required=True) - kwds = self._prepare_settings(handler, category, **kwds) #XXX: could insert normalization to preferred unicode encoding here - return handler.encrypt(secret, **kwds) + return self._get_record(scheme, category).encrypt(secret, **kwds) def verify(self, secret, hash, scheme=None, category=None, **context): """verify secret against specified hash. @@ -1032,42 +1421,15 @@ class CryptContext(object): :returns: True/False """ - #quick checks if hash is None: return False - - mvt = self.policy.get_min_verify_time(category) - if mvt: - start = time.time() - - #locate handler if scheme: - handler = self.policy.get_handler(scheme, required=True) + record = self._get_record(scheme, category) else: - handler = self.identify(hash, resolve=True, required=True) - - #strip context kwds if scheme doesn't use them - ##for k in context.keys(): - ## if k not in handler.context_kwds: - ## del context[k] - - #XXX: could insert normalization to preferred unicode encoding here - - #use handler to verify secret - result = handler.verify(secret, hash, **context) - - if mvt: - #delta some amount of time if verify took less than mvt seconds - end = time.time() - delta = mvt + start - end - if delta > 0: - time.sleep(delta) - elif delta < 0: - #warn app they aren't being protected against timing attacks... - warn("CryptContext: verify exceeded min_verify_time: scheme=%r min_verify_time=%r elapsed=%r" % - (handler.name, mvt, end-start)) - - return result + record = self._identify_record(hash, category) + # XXX: strip context kwds if scheme doesn't use them? + # XXX: could insert normalization to preferred unicode encoding here + return record.verify(secret, hash, **context) def verify_and_update(self, secret, hash, scheme=None, category=None, **kwds): """verify secret and check if hash needs upgrading, in a single call. @@ -1108,11 +1470,18 @@ class CryptContext(object): .. seealso:: :ref:`context-migrating-passwords` for a usage example. """ - ok = self.verify(secret, hash, scheme=scheme, category=category, **kwds) - if not ok: + if hash is None: + return False, None + if scheme: + record = self._get_record(scheme, category) + else: + record = self._identify_record(hash, category) + # XXX: strip context kwds if scheme doesn't use them? + # XXX: could insert normalization to preferred unicode encoding here + if not record.verify(secret, hash, **kwds): return False, None - if self.hash_needs_update(hash, category=category): - return True, self.encrypt(secret, category=category, **kwds) + elif record.hash_needs_update(hash): + return True, self.encrypt(secret, None, category, **kwds) else: return True, None @@ -1157,6 +1526,12 @@ class LazyCryptContext(CryptContext): """ _lazy_kwds = None + # NOTE: the way this class works changed in 1.6. + # previously it just called _lazy_init() when ``.policy`` was + # first accessed. now that is done whenever any of the public + # attributes are accessed, and the class itself is changed + # to a regular CryptContext, to remove the overhead one it's unneeded. + def __init__(self, schemes=None, **kwds): if schemes is not None: kwds['schemes'] = schemes @@ -1164,25 +1539,17 @@ class LazyCryptContext(CryptContext): def _lazy_init(self): kwds = self._lazy_kwds - del self._lazy_kwds if 'create_policy' in kwds: create_policy = kwds.pop("create_policy") kwds = dict(policy=create_policy(**kwds)) super(LazyCryptContext, self).__init__(**kwds) + del self._lazy_kwds + self.__class__ = CryptContext - #NOTE: 'policy' property calls _lazy_init the first time it's accessed, - # and relies on CryptContext.__init__ to replace it with an actual instance. - # it should then have no more effect from then on. - class _PolicyProperty(object): - - def __get__(self, obj, cls): - if obj is None: - return self - obj._lazy_init() - assert isinstance(obj.policy, CryptPolicy) - return obj.policy - - policy = _PolicyProperty() + def __getattribute__(self, attr): + if not attr.startswith("_"): + self._lazy_init() + return object.__getattribute__(self, attr) #========================================================= # eof diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py index 30af9ed..4aa0f01 100644 --- a/passlib/handlers/bcrypt.py +++ b/passlib/handlers/bcrypt.py @@ -153,7 +153,7 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. #========================================================= @classmethod - def _hash_needs_update(cls, hash, **opts): + def _hash_needs_update(cls, hash): if isinstance(hash, bytes): hash = hash.decode("ascii") if hash.startswith(u("$2a$")) and hash[28] not in BSLAST: diff --git a/passlib/registry.py b/passlib/registry.py index 8fdd61d..31f5b74 100644 --- a/passlib/registry.py +++ b/passlib/registry.py @@ -182,6 +182,30 @@ def register_crypt_handler_path(name, path): modname, modattr = path, name _handler_locations[name] = (modname, modattr) +def _validate_handler_name(name): + """helper to validate handler name + + :raises ValueError: + * if empty name + * if name not lower case + * if name contains double underscores + * if name is reserved (e.g. ``context``, ``all``). + """ + if not name: + raise ValueError("handler name cannot be empty: %r" % (name,)) + if name.lower() != name: + raise ValueError("name must be lower-case: %r" % (name,)) + if not _name_re.match(name): + raise ValueError("invalid characters in name (must be 3+ characters, " + " begin with a-z, and contain only underscore, a-z, " + "0-9): %r" % (name,)) + if '__' in name: + raise ValueError("name may not contain double-underscores: %r" % + (name,)) + if name in _forbidden_names: + raise ValueError("that name is not allowed: %r" % (name,)) + return True + def register_crypt_handler(handler, force=False, name=None): """register password hash handler. @@ -219,18 +243,7 @@ def register_crypt_handler(handler, force=False, name=None): raise ValueError("handlers must be stored only under their own name") else: name = handler.name - - #validate name - if not name: - raise ValueError("name is null: %r" % (name,)) - if name.lower() != name: - raise ValueError("name must be lower-case: %r" % (name,)) - if not _name_re.match(name): - raise ValueError("invalid characters in name (must be 3+ characters, begin with a-z, and contain only underscore, a-z, 0-9): %r" % (name,)) - if '__' in name: - raise ValueError("name may not contain double-underscores: %r" % (name,)) - if name in _forbidden_names: - raise ValueError("that name is not allowed: %r" % (name,)) + _validate_handler_name(name) #check for existing handler other = _handlers.get(name) diff --git a/passlib/tests/test_context.py b/passlib/tests/test_context.py index 187dff4..712f92c 100644 --- a/passlib/tests/test_context.py +++ b/passlib/tests/test_context.py @@ -18,7 +18,7 @@ except ImportError: #pkg from passlib import hash from passlib.context import CryptContext, CryptPolicy, LazyCryptContext -from passlib.utils import to_bytes, to_unicode +from passlib.utils import to_bytes, to_unicode, PasslibPolicyWarning from passlib.utils.compat import irange import passlib.utils.handlers as uh from passlib.tests.utils import TestCase, mktemp, catch_warnings, \ @@ -88,7 +88,7 @@ sha512_crypt.min_rounds = 40000 sample_config_1prd = dict( schemes = [ hash.des_crypt, hash.md5_crypt, hash.bsdi_crypt, hash.sha512_crypt], - default = hash.md5_crypt, + default = "md5_crypt", # NOTE: passlib <= 1.5 was handler obj. all__vary_rounds = "10%", bsdi_crypt__max_rounds = 30000, bsdi_crypt__default_rounds = 25000, @@ -201,16 +201,16 @@ admin__context__deprecated = des_crypt, bsdi_crypt policy = CryptPolicy(**self.sample_config_1pd) self.assertEqual(policy.to_dict(), self.sample_config_1pd) - #check with bad key - self.assertRaises(KeyError, CryptPolicy, + #check key with too many separators is rejected + self.assertRaises(TypeError, CryptPolicy, schemes = [ "des_crypt", "md5_crypt", "bsdi_crypt", "sha512_crypt"], bad__key__bsdi_crypt__max_rounds = 30000, ) - #check with bad handler - self.assertRaises(TypeError, CryptPolicy, schemes=[uh.StaticHandler]) + #check nameless handler rejected + self.assertRaises(ValueError, CryptPolicy, schemes=[uh.StaticHandler]) - #check with multiple handlers + #check name conflicts are rejected class dummy_1(uh.StaticHandler): name = 'dummy_1' self.assertRaises(KeyError, CryptPolicy, schemes=[dummy_1, dummy_1]) @@ -452,7 +452,19 @@ admin__context__deprecated = des_crypt, bsdi_crypt self.assertTrue(pb.handler_is_deprecated("des_crypt", "admin")) self.assertTrue(pb.handler_is_deprecated("bsdi_crypt", "admin")) + # check deprecation is overridden per category + pc = CryptPolicy( + schemes=["md5_crypt", "des_crypt"], + deprecated=["md5_crypt"], + user__context__deprecated=["des_crypt"], + ) + self.assertTrue(pc.handler_is_deprecated("md5_crypt")) + self.assertFalse(pc.handler_is_deprecated("des_crypt")) + self.assertFalse(pc.handler_is_deprecated("md5_crypt", "user")) + self.assertTrue(pc.handler_is_deprecated("des_crypt", "user")) + def test_15_min_verify_time(self): + "test get_min_verify_time() method" pa = CryptPolicy() self.assertEqual(pa.get_min_verify_time(), 0) self.assertEqual(pa.get_min_verify_time('admin'), 0) @@ -469,10 +481,6 @@ admin__context__deprecated = des_crypt, bsdi_crypt self.assertEqual(pd.get_min_verify_time(), .1) self.assertEqual(pd.get_min_verify_time('admin'), .2) - #TODO: test this. - ##def test_gen_min_verify_time(self): - ## "test get_min_verify_time() method" - #========================================================= #serialization #========================================================= @@ -577,11 +585,15 @@ class CryptContextTest(TestCase): nthash__ident = "NT", ) - def test_10_genconfig_settings(self): - "test genconfig() honors policy settings" - cc = CryptContext(policy=None, **self.sample_policy_1) + def test_10_01_genconfig_settings(self): + "test genconfig() settings" + cc = CryptContext(policy=None, + schemes=["md5_crypt", "nthash"], + nthash__ident="NT", + ) # hash specific settings + self.assertTrue(cc.genconfig().startswith("$1$")) self.assertEqual( cc.genconfig(scheme="nthash"), '$NT$00000000000000000000000000000000', @@ -591,73 +603,192 @@ class CryptContextTest(TestCase): '$3$$00000000000000000000000000000000', ) + def test_10_02_genconfig_rounds_limits(self): + "test genconfig() policy rounds limits" + cc = CryptContext(policy=None, + schemes=["sha256_crypt"], + all__min_rounds=2000, + all__max_rounds=3000, + all__default_rounds=2500, + ) + # min rounds - self.assertEqual( - cc.genconfig(rounds=1999, salt="nacl"), - '$5$rounds=2000$nacl$', - ) - self.assertEqual( - cc.genconfig(rounds=2001, salt="nacl"), - '$5$rounds=2001$nacl$' - ) + with catch_warnings(record=True) as wlog: - #max rounds - self.assertEqual( - cc.genconfig(rounds=2999, salt="nacl"), - '$5$rounds=2999$nacl$', - ) - self.assertEqual( - cc.genconfig(rounds=3001, salt="nacl"), - '$5$rounds=3000$nacl$' - ) + # set below handler min + c2 = cc.replace(all__min_rounds=500, all__max_rounds=None, + all__default_rounds=500) + self.assertWarningMatches(wlog.pop(), category=PasslibPolicyWarning) + self.assertWarningMatches(wlog.pop(), category=PasslibPolicyWarning) + self.assertEqual(c2.genconfig(salt="nacl"), "$5$rounds=1000$nacl$") + self.assertNoWarnings(wlog) - #default rounds - specified - self.assertEqual( - cc.genconfig(scheme="bsdi_crypt", salt="nacl"), - '_N...nacl', - ) + # below + self.assertEqual( + cc.genconfig(rounds=1999, salt="nacl"), + '$5$rounds=2000$nacl$', + ) + self.assertWarningMatches(wlog.pop(), category=PasslibPolicyWarning) + self.assertNoWarnings(wlog) - #default rounds - fall back to max rounds - self.assertEqual( - cc.genconfig(salt="nacl"), - '$5$rounds=3000$nacl$', - ) + # equal + self.assertEqual( + cc.genconfig(rounds=2000, salt="nacl"), + '$5$rounds=2000$nacl$', + ) + self.assertNoWarnings(wlog) - #default rounds - out of bounds - cc2 = CryptContext(policy=cc.policy.replace( - bsdi_crypt__default_rounds=35)) - self.assertEqual( - cc2.genconfig(scheme="bsdi_crypt", salt="nacl"), - '_S...nacl', - ) + # above + self.assertEqual( + cc.genconfig(rounds=2001, salt="nacl"), + '$5$rounds=2001$nacl$' + ) + self.assertNoWarnings(wlog) - # default+vary rounds - # this runs enough times the min and max *should* be hit, - # though there's a faint chance it will randomly fail. - from passlib.hash import bsdi_crypt as bc - cc3 = CryptContext(policy=cc.policy.replace( - bsdi_crypt__vary_rounds = 3)) - seen = set() - for i in irange(3*2*50): - h = cc3.genconfig("bsdi_crypt", salt="nacl") - r = bc.from_string(h).rounds - seen.add(r) - self.assertTrue(min(seen)==22) - self.assertTrue(max(seen)==28) + # max rounds + with catch_warnings(record=True) as wlog: + # set above handler max + c2 = cc.replace(all__max_rounds=int(1e9)+500, all__min_rounds=None, + all__default_rounds=int(1e9)+500) + self.assertWarningMatches(wlog.pop(), category=PasslibPolicyWarning) + self.assertWarningMatches(wlog.pop(), category=PasslibPolicyWarning) + self.assertEqual(c2.genconfig(salt="nacl"), + "$5$rounds=999999999$nacl$") + self.assertNoWarnings(wlog) + + # above + self.assertEqual( + cc.genconfig(rounds=3001, salt="nacl"), + '$5$rounds=3000$nacl$' + ) + self.assertWarningMatches(wlog.pop(), category=PasslibPolicyWarning) + self.assertNoWarnings(wlog) + + # equal + self.assertEqual( + cc.genconfig(rounds=3000, salt="nacl"), + '$5$rounds=3000$nacl$' + ) + self.assertNoWarnings(wlog) + + # below + self.assertEqual( + cc.genconfig(rounds=2999, salt="nacl"), + '$5$rounds=2999$nacl$', + ) + self.assertNoWarnings(wlog) + + # explicit default rounds + self.assertEqual(cc.genconfig(salt="nacl"), '$5$rounds=2500$nacl$') + + # fallback default rounds - use handler's + c2 = cc.replace(all__default_rounds=None, all__max_rounds=50000) + self.assertEqual(c2.genconfig(salt="nacl"), '$5$rounds=40000$nacl$') - # default+vary % rounds - # this runs enough times the min and max *should* be hit, + # fallback default rounds - use handler's, but clipped to max rounds + c2 = cc.replace(all__default_rounds=None, all__max_rounds=3000) + self.assertEqual(c2.genconfig(salt="nacl"), '$5$rounds=3000$nacl$') + + # TODO: test default falls back to mx / mn if handler has no default. + + #default rounds - out of bounds + self.assertRaises(ValueError, cc.replace, all__default_rounds=1999) + cc.policy.replace(all__default_rounds=2000) + cc.policy.replace(all__default_rounds=3000) + self.assertRaises(ValueError, cc.replace, all__default_rounds=3001) + + # invalid min/max bounds + c2 = CryptContext(policy=None, schemes=["sha256_crypt"]) + self.assertRaises(ValueError, c2.replace, all__min_rounds=-1) + self.assertRaises(ValueError, c2.replace, all__max_rounds=-1) + self.assertRaises(ValueError, c2.replace, all__min_rounds=2000, + all__max_rounds=1999) + + def test_10_03_genconfig_linear_vary_rounds(self): + "test genconfig() linear vary rounds" + cc = CryptContext(policy=None, + schemes=["sha256_crypt"], + all__min_rounds=1995, + all__max_rounds=2005, + all__default_rounds=2000, + ) + + # test negative + self.assertRaises(ValueError, cc.replace, all__vary_rounds=-1) + self.assertRaises(ValueError, cc.replace, all__vary_rounds="-1%") + self.assertRaises(ValueError, cc.replace, all__vary_rounds="101%") + + # test static + c2 = cc.replace(all__vary_rounds=0) + self.assert_rounds_range(c2, "sha256_crypt", 2000, 2000) + + c2 = cc.replace(all__vary_rounds="0%") + self.assert_rounds_range(c2, "sha256_crypt", 2000, 2000) + + # test absolute + c2 = cc.replace(all__vary_rounds=1) + self.assert_rounds_range(c2, "sha256_crypt", 1999, 2001) + c2 = cc.replace(all__vary_rounds=100) + self.assert_rounds_range(c2, "sha256_crypt", 1995, 2005) + + # test relative + c2 = cc.replace(all__vary_rounds="0.1%") + self.assert_rounds_range(c2, "sha256_crypt", 1998, 2002) + c2 = cc.replace(all__vary_rounds="100%") + self.assert_rounds_range(c2, "sha256_crypt", 1995, 2005) + + def test_10_03_genconfig_log2_vary_rounds(self): + "test genconfig() log2 vary rounds" + cc = CryptContext(policy=None, + schemes=["bcrypt"], + all__min_rounds=15, + all__max_rounds=25, + all__default_rounds=20, + ) + + # test negative + self.assertRaises(ValueError, cc.replace, all__vary_rounds=-1) + self.assertRaises(ValueError, cc.replace, all__vary_rounds="-1%") + self.assertRaises(ValueError, cc.replace, all__vary_rounds="101%") + + # test static + c2 = cc.replace(all__vary_rounds=0) + self.assert_rounds_range(c2, "bcrypt", 20, 20) + + c2 = cc.replace(all__vary_rounds="0%") + self.assert_rounds_range(c2, "bcrypt", 20, 20) + + # test absolute + c2 = cc.replace(all__vary_rounds=1) + self.assert_rounds_range(c2, "bcrypt", 19, 21) + c2 = cc.replace(all__vary_rounds=100) + self.assert_rounds_range(c2, "bcrypt", 15, 25) + + # test relative - should shift over at 50% mark + c2 = cc.replace(all__vary_rounds="1%") + self.assert_rounds_range(c2, "bcrypt", 20, 20) + + c2 = cc.replace(all__vary_rounds="49%") + self.assert_rounds_range(c2, "bcrypt", 20, 20) + + c2 = cc.replace(all__vary_rounds="50%") + self.assert_rounds_range(c2, "bcrypt", 19, 20) + + c2 = cc.replace(all__vary_rounds="100%") + self.assert_rounds_range(c2, "bcrypt", 15, 21) + + def assert_rounds_range(self, context, scheme, lower, upper, salt="."*22): + "helper to check vary_rounds covers specified range" + # NOTE: this runs enough times the min and max *should* be hit, # though there's a faint chance it will randomly fail. - from passlib.hash import sha256_crypt as sc - cc4 = CryptContext(policy=cc.policy.replace( - all__vary_rounds = "1%")) + handler = context.policy.get_handler(scheme) seen = set() - for i in irange(30*50): - h = cc4.genconfig(salt="nacl") - r = sc.from_string(h).rounds + for i in irange(300): + h = context.genconfig(scheme, salt=salt) + r = handler.from_string(h).rounds seen.add(r) - self.assertTrue(min(seen)==2970) - self.assertTrue(max(seen)==3000) #NOTE: would be 3030, but clipped by max_rounds + self.assertEqual(min(seen), lower, "vary_rounds lower bound:") + self.assertEqual(max(seen), upper, "vary_rounds upper bound:") def test_11_encrypt_settings(self): "test encrypt() honors policy settings" @@ -673,33 +804,29 @@ class CryptContextTest(TestCase): '$3$$8846f7eaee8fb117ad06bdd830b7586c', ) + # NOTE: more thorough job of rounds limits done in genconfig() test, + # which is much cheaper, and shares the same codebase. + # min rounds - self.assertEqual( - cc.encrypt("password", rounds=1999, salt="nacl"), - '$5$rounds=2000$nacl$9/lTZ5nrfPuz8vphznnmHuDGFuvjSNvOEDsGmGfsS97', - ) - self.assertEqual( - cc.encrypt("password", rounds=2001, salt="nacl"), - '$5$rounds=2001$nacl$8PdeoPL4aXQnJ0woHhqgIw/efyfCKC2WHneOpnvF.31' - ) + with catch_warnings(record=True) as wlog: + self.assertEqual( + cc.encrypt("password", rounds=1999, salt="nacl"), + '$5$rounds=2000$nacl$9/lTZ5nrfPuz8vphznnmHuDGFuvjSNvOEDsGmGfsS97', + ) + self.assertWarningMatches(wlog.pop(), category=PasslibPolicyWarning) + self.assertFalse(wlog) - #TODO: - # max rounds - # default rounds - # falls back to max, then min. - # specified - # outside of min/max range - # default+vary rounds - # default+vary % rounds - - #make sure default > max doesn't cause error when vary is set - cc2 = cc.replace(sha256_crypt__default_rounds=4000) - with catch_warnings(): - warnings.filterwarnings("ignore", "vary default rounds: lower bound > upper bound.*", UserWarning) self.assertEqual( - cc2.encrypt("password", salt="nacl"), - '$5$rounds=3000$nacl$oH831OVMbkl.Lbw1SXflly4dW8L3mSxpxDz1u1CK/B0', + cc.encrypt("password", rounds=2001, salt="nacl"), + '$5$rounds=2001$nacl$8PdeoPL4aXQnJ0woHhqgIw/efyfCKC2WHneOpnvF.31' ) + self.assertFalse(wlog) + + # max rounds, etc tested in genconfig() + + # make default > max throws error if attempted + self.assertRaises(ValueError, cc.replace, + sha256_crypt__default_rounds=4000) def test_12_hash_needs_update(self): "test hash_needs_update() method" @@ -707,14 +834,14 @@ class CryptContextTest(TestCase): #check deprecated scheme self.assertTrue(cc.hash_needs_update('9XXD4trGYeGJA')) - self.assertTrue(not cc.hash_needs_update('$1$J8HC2RCr$HcmM.7NxB2weSvlw2FgzU0')) + self.assertFalse(cc.hash_needs_update('$1$J8HC2RCr$HcmM.7NxB2weSvlw2FgzU0')) #check min rounds self.assertTrue(cc.hash_needs_update('$5$rounds=1999$jD81UCoo.zI.UETs$Y7qSTQ6mTiU9qZB4fRr43wRgQq4V.5AAf7F97Pzxey/')) - self.assertTrue(not cc.hash_needs_update('$5$rounds=2000$228SSRje04cnNCaQ$YGV4RYu.5sNiBvorQDlO0WWQjyJVGKBcJXz3OtyQ2u8')) + self.assertFalse(cc.hash_needs_update('$5$rounds=2000$228SSRje04cnNCaQ$YGV4RYu.5sNiBvorQDlO0WWQjyJVGKBcJXz3OtyQ2u8')) #check max rounds - self.assertTrue(not cc.hash_needs_update('$5$rounds=3000$fS9iazEwTKi7QPW4$VasgBC8FqlOvD7x2HhABaMXCTh9jwHclPA9j5YQdns.')) + self.assertFalse(cc.hash_needs_update('$5$rounds=3000$fS9iazEwTKi7QPW4$VasgBC8FqlOvD7x2HhABaMXCTh9jwHclPA9j5YQdns.')) self.assertTrue(cc.hash_needs_update('$5$rounds=3001$QlFHHifXvpFX4PLs$/0ekt7lSs/lOikSerQ0M/1porEHxYq7W/2hdFpxA3fA')) #========================================================= diff --git a/passlib/tests/test_utils.py b/passlib/tests/test_utils.py index 26d6386..70090e8 100644 --- a/passlib/tests/test_utils.py +++ b/passlib/tests/test_utils.py @@ -143,6 +143,16 @@ class MiscTest(TestCase): else: self.assertEqual(ret, (True, u('aaOx.5nbTU/.M'))) + # test safe_os_crypt() handles os_crypt() returning None + # (Python's Modules/_cryptmodule.c notes some platforms may do this + # when algorithm is not supported) + orig = utils.os_crypt + try: + utils.os_crypt = lambda secret, hash: None + self.assertEqual(safe_os_crypt(u'test', u'aa'), (False,None)) + finally: + utils.os_crypt = orig + def test_consteq(self): "test consteq()" # NOTE: this test is kind of over the top, but that's only because @@ -227,6 +237,82 @@ class MiscTest(TestCase): ## ## first = False ## ##print ", ".join(str(c) for c in [run] + times) + def test_saslprep(self): + "test saslprep() unicode normalizer" + from passlib.utils import saslprep as sp + + # invalid types + self.assertRaises(TypeError, sp, None) + self.assertRaises(TypeError, sp, 1) + self.assertRaises(TypeError, sp, b('')) + + # empty strings + self.assertEqual(sp(u''), u'') + self.assertEqual(sp(u'\u00AD'), u'') + + # verify B.1 chars are stripped, + self.assertEqual(sp(u"$\u00AD$\u200D$"), u"$$$") + + # verify C.1.2 chars are replaced with space + self.assertEqual(sp(u"$ $\u00A0$\u3000$"), u"$ $ $ $") + + # verify normalization to KC + self.assertEqual(sp(u"a\u0300"), u"\u00E0") + self.assertEqual(sp(u"\u00E0"), u"\u00E0") + + # verify various forbidden characters + # control chars + self.assertRaises(ValueError, sp, u"\u0000") + self.assertRaises(ValueError, sp, u"\u007F") + self.assertRaises(ValueError, sp, u"\u180E") + self.assertRaises(ValueError, sp, u"\uFFF9") + # private use + self.assertRaises(ValueError, sp, u"\uE000") + # non-characters + self.assertRaises(ValueError, sp, u"\uFDD0") + # surrogates + self.assertRaises(ValueError, sp, u"\uD800") + # non-plaintext chars + self.assertRaises(ValueError, sp, u"\uFFFD") + # non-canon + self.assertRaises(ValueError, sp, u"\u2FF0") + # change display properties + self.assertRaises(ValueError, sp, u"\u200E") + self.assertRaises(ValueError, sp, u"\u206F") + # unassigned code points (as of unicode 3.2) + self.assertRaises(ValueError, sp, u"\u0900") + self.assertRaises(ValueError, sp, u"\uFFF8") + + # verify bidi behavior + # if starts with R/AL -- must end with R/AL + self.assertRaises(ValueError, sp, u"\u0627\u0031") + self.assertEqual(sp(u"\u0627"), u"\u0627") + self.assertEqual(sp(u"\u0627\u0628"), u"\u0627\u0628") + self.assertEqual(sp(u"\u0627\u0031\u0628"), u"\u0627\u0031\u0628") + # if starts with R/AL -- cannot contain L + self.assertRaises(ValueError, sp, u"\u0627\u0041\u0628") + # if doesn't start with R/AL -- can contain R/AL, but L & EN allowed + self.assertRaises(ValueError, sp, u"x\u0627z") + self.assertEqual(sp(u"x\u0041z"), u"x\u0041z") + + #------------------------------------------------------ + # examples pulled from external sources, to be thorough + #------------------------------------------------------ + + # rfc 4031 section 3 examples + self.assertEqual(sp(u"I\u00ADX"), u"IX") # strip SHY + self.assertEqual(sp(u"user"), u"user") # unchanged + self.assertEqual(sp(u"USER"), u"USER") # case preserved + self.assertEqual(sp(u"\u00AA"), u"a") # normalize to KC form + self.assertEqual(sp(u"\u2168"), u"IX") # normalize to KC form + self.assertRaises(ValueError, sp, u"\u0007") # forbid control chars + self.assertRaises(ValueError, sp, u"\u0627\u0031") # invalid bidi + + # rfc 3454 section 6 examples + # starts with RAL char, must end with RAL char + self.assertRaises(ValueError, sp, u"\u0627\u0031") + self.assertEqual(sp(u"\u0627\u0031\u0628"), u"\u0627\u0031\u0628") + #========================================================= #byte/unicode helpers #========================================================= diff --git a/passlib/tests/test_utils_handlers.py b/passlib/tests/test_utils_handlers.py index 30c4061..4ed5f06 100644 --- a/passlib/tests/test_utils_handlers.py +++ b/passlib/tests/test_utils_handlers.py @@ -10,7 +10,7 @@ from logging import getLogger import warnings #site #pkg -from passlib.hash import ldap_md5 +from passlib.hash import ldap_md5, sha256_crypt from passlib.registry import _unload_handler_name as unload_handler_name, \ register_crypt_handler, get_crypt_handler from passlib.utils import rng, getrandstr, handlers as uh, bytes, b, \ @@ -332,6 +332,11 @@ class PrefixWrapperTest(TestCase): d1 = uh.PrefixWrapper("d1", "ldap_md5", "{XXX}", "{MD5}") self.assertEqual(d1.name, "d1") self.assertIs(d1.setting_kwds, ldap_md5.setting_kwds) + self.assertFalse('max_rounds' in dir(d1)) + + d2 = uh.PrefixWrapper("d2", "sha256_crypt", "{XXX}") + self.assertIs(d2.setting_kwds, sha256_crypt.setting_kwds) + self.assertTrue('max_rounds' in dir(d2)) def test_11_wrapped_methods(self): d1 = uh.PrefixWrapper("d1", "ldap_md5", "{XXX}", "{MD5}") @@ -357,6 +362,34 @@ class PrefixWrapperTest(TestCase): self.assertRaises(ValueError, d1.verify, "password", lph) self.assertTrue(d1.verify("password", dph)) + def test_12_ident(self): + # test ident is proxied + h = uh.PrefixWrapper("h2", "ldap_md5", "{XXX}") + self.assertEqual(h.ident, u"{XXX}{MD5}") + self.assertIs(h.ident_values, None) + + # test orig_prefix disabled ident proxy + h = uh.PrefixWrapper("h1", "ldap_md5", "{XXX}", "{MD5}") + self.assertIs(h.ident, None) + self.assertIs(h.ident_values, None) + + # test custom ident overrides default + h = uh.PrefixWrapper("h3", "ldap_md5", "{XXX}", ident="{X") + self.assertEqual(h.ident, u"{X") + self.assertIs(h.ident_values, None) + + # test custom ident must match + h = uh.PrefixWrapper("h3", "ldap_md5", "{XXX}", ident="{XXX}A") + self.assertRaises(ValueError, uh.PrefixWrapper, "h3", "ldap_md5", + "{XXX}", ident="{XY") + self.assertRaises(ValueError, uh.PrefixWrapper, "h3", "ldap_md5", + "{XXX}", ident="{XXXX") + + # test ident_values is proxied + h = uh.PrefixWrapper("h4", "bcrypt", "{XXX}") + self.assertIs(h.ident, None) + self.assertEqual(h.ident_values, [ u"{XXX}$2$", u"{XXX}$2a$" ]) + #========================================================= #sample algorithms - these serve as known quantities # to test the unittests themselves, as well as other diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py index 058f98e..cb72143 100644 --- a/passlib/tests/utils.py +++ b/passlib/tests/utils.py @@ -367,6 +367,19 @@ class TestCase(unittest.TestCase): ## raise TypeError("can't read lineno from warning object") ## self.assertEqual(wmsg.lineno, lineno, msg) + def assertNoWarnings(self, wlist, msg=None): + "assert that list (e.g. from catch_warnings) contains no warnings" + if not wlist: + return + wout = [self._formatWarning(w.message) for w in wlist] + std = "AssertionError: unexpected warnings: " + ", ".join(wout) + msg = self._formatMessage(msg, std) + raise self.failureException(msg) + + def _formatWarning(self, entry): + cls = type(entry) + return "<%s.%s %r>" % (cls.__module__,cls.__name__, str(entry)) + #============================================================ #eoc #============================================================ diff --git a/passlib/utils/__init__.py b/passlib/utils/__init__.py index b6116c8..7621ddd 100644 --- a/passlib/utils/__init__.py +++ b/passlib/utils/__init__.py @@ -12,7 +12,9 @@ from math import log as logb import os import sys import random +import stringprep import time +import unicodedata from warnings import warn #site #pkg @@ -42,6 +44,10 @@ __all__ = [ 'to_native_str', 'is_same_codec', + # string manipulation + 'consteq', + 'saslprep', + #byte manipulation "xor_bytes", @@ -116,6 +122,20 @@ class MissingBackendError(RuntimeError): from :class:`~passlib.utils.handlers.HasManyBackends`. """ +class PasslibPolicyWarning(UserWarning): + """Warning issued when non-fatal issue is found in policy configuration. + + This occurs primarily in one of two cases: + + * the policy contains rounds limits which exceed the hard limits + imposed by the underlying algorithm. + * an explicit rounds value was provided which exceeds the limits + imposed by the policy. + + In both of these cases, the code will perform correctly & securely; + but the warning is issued as a sign the configuration may need updating. + """ + #========================================================== #bytes compat aliases - bytes, native_str, b() #========================================================== @@ -156,7 +176,8 @@ else: # happen, but being paranoid. warn("utf-8 password didn't re-encode correctly!") return False, None - return True, os_crypt(secret, hash) + result = os_crypt(secret, hash) + return (result is not None), result else: def safe_os_crypt(secret, hash): # NOTE: this guard logic is designed purely to match py3 behavior, @@ -167,7 +188,11 @@ else: raise TypeError("hash must be unicode") else: hash = hash.encode("utf-8") - return True, os_crypt(secret, hash).decode("ascii") + result = os_crypt(secret, hash) + if result is None: + return False, None + else: + return True, result.decode("ascii") _add_doc(safe_os_crypt, """wrapper around stdlib's crypt. @@ -429,6 +454,9 @@ def is_ascii_safe(source): #================================================================================= #string helpers #================================================================================= +UEMPTY = u"" +USPACE = u" " +ujoin = UEMPTY.join def consteq(left, right): """check two strings/bytes for equality, taking constant time relative @@ -492,13 +520,131 @@ def consteq(left, right): return result == 0 def splitcomma(source, sep=","): - "split comma-separated string into list of elements, stripping whitespace and discarding empty elements" + """split comma-separated string into list of elements, + stripping whitespace and discarding empty elements. + + .. deprecated:: 1.6, will be removed in 1.7 + """ + warn("splitcomma() is deprecated, will be removed in passlib 1.7", + DeprecationWarning, stacklevel=2) return [ elem.strip() for elem in source.split(sep) if elem.strip() ] +def saslprep(source, errname="value"): + """normalizes unicode string using SASLPrep stringprep profile. + + The SASLPrep profile is defined in :rfc:`4013`. + It provides a uniform scheme for normalizing unicode usernames + and passwords before performing byte-value sensitive operations + such as hashing. Among other things, it normalizes diacritic + representations, removes non-printing characters, and forbids + invalid characters such as ``\n``. + + :arg source: + unicode string to normalize & validate + + :param errname: + optionally override noun used to refer to source in error messages, + defaults to ``value``; mainly useful to make caller's error + messages make more sense. + + :raises ValueError: + if any characters forbidden by the SASLPrep profile are encountered. + + :returns: + normalized unicode string + """ + # saslprep - http://tools.ietf.org/html/rfc4013 + # stringprep - http://tools.ietf.org/html/rfc3454 + # http://docs.python.org/library/stringprep.html + + # validate type + if not isinstance(source, unicode): + raise TypeError("input must be unicode string, not %s" % + (type(source),)) + + # mapping stage + # - map non-ascii spaces to U+0020 (stringprep C.1.2) + # - strip 'commonly mapped to nothing' chars (stringprep B.1) + in_table_c12 = stringprep.in_table_c12 + in_table_b1 = stringprep.in_table_b1 + data = ujoin( + USPACE if in_table_c12(c) else c + for c in source + if not in_table_b1(c) + ) + + # normalize to KC form + data = unicodedata.normalize('NFKC', data) + if not data: + return UEMPTY + + # check for invalid bi-directional strings. + # stringprep requires the following: + # - chars in C.8 must be prohibited. + # - if any R/AL chars in string: + # - no L chars allowed in string + # - first and last must be R/AL chars + # this checks if start/end are R/AL chars. if so, prohibited loop + # will forbid all L chars. if not, prohibited loop will forbid all + # R/AL chars instead. in both cases, prohibited loop takes care of C.8. + is_ral_char = stringprep.in_table_d1 + if is_ral_char(data[0]): + if not is_ral_char(data[-1]): + raise ValueError("malformed bidi sequence in " + errname) + # forbid L chars within R/AL sequence. + is_forbidden_bidi_char = stringprep.in_table_d2 + else: + # forbid R/AL chars if start not setup correctly; L chars allowed. + is_forbidden_bidi_char = is_ral_char + + # check for prohibited output - stringprep tables A.1, B.1, C.1.2, C.2 - C.9 + in_table_a1 = stringprep.in_table_a1 + in_table_c21_c22 = stringprep.in_table_c21_c22 + in_table_c3 = stringprep.in_table_c3 + in_table_c4 = stringprep.in_table_c4 + in_table_c5 = stringprep.in_table_c5 + in_table_c6 = stringprep.in_table_c6 + in_table_c7 = stringprep.in_table_c7 + in_table_c8 = stringprep.in_table_c8 + in_table_c9 = stringprep.in_table_c9 + for c in data: + # check for this mapping stage should have removed + assert not in_table_b1(c), "failed to strip B.1 in mapping stage" + assert not in_table_c12(c), "failed to replace C.1.2 in mapping stage" + + # check for forbidden chars + if in_table_a1(c): + raise ValueError("unassigned code points forbidden in " + errname) + if in_table_c21_c22(c): + raise ValueError("control characters forbidden in " + errname) + if in_table_c3(c): + raise ValueError("private use characters forbidden in " + errname) + if in_table_c4(c): + raise ValueError("non-char code points forbidden in " + errname) + if in_table_c5(c): + raise ValueError("surrogate codes forbidden in " + errname) + if in_table_c6(c): + raise ValueError("non-plaintext chars forbidden in " + errname) + if in_table_c7(c): + # XXX: should these have been caught by normalize? + # if so, should change this to an assert + raise ValueError("non-canonical chars forbidden in " + errname) + if in_table_c8(c): + raise ValueError("display-modifying / deprecated chars " + "forbidden in" + errname) + if in_table_c9(c): + raise ValueError("tagged characters forbidden in " + errname) + + # do bidi constraint check chosen by bidi init, above + if is_forbidden_bidi_char(c): + raise ValueError("forbidden bidi character in " + errname) + + return data + #========================================================== #bytes helpers #========================================================== @@ -508,7 +654,6 @@ BEMPTY = b('') #helpers for joining / extracting elements bjoin = BEMPTY.join -ujoin = u('').join #def bjoin_elems(elems): # """takes series of bytes elements, returns bytes. diff --git a/passlib/utils/handlers.py b/passlib/utils/handlers.py index 11e3614..98c61fa 100644 --- a/passlib/utils/handlers.py +++ b/passlib/utils/handlers.py @@ -1157,7 +1157,8 @@ class PrefixWrapper(object): :param lazy: if True and wrapped handler is specified by name, don't look it up until needed. """ - def __init__(self, name, wrapped, prefix=u(''), orig_prefix=u(''), lazy=False, doc=None): + def __init__(self, name, wrapped, prefix=u(''), orig_prefix=u(''), lazy=False, + doc=None, ident=None): self.name = name if isinstance(prefix, bytes): prefix = prefix.decode("ascii") @@ -1175,6 +1176,13 @@ class PrefixWrapper(object): if not lazy: self._get_wrapped() + if ident is not None: + if isinstance(ident, bytes): + ident = ident.decode("ascii") + if ident[:len(prefix)] != prefix[:len(ident)]: + raise ValueError("ident agree with prefix") + self._ident = ident + _wrapped_name = None _wrapped_handler = None @@ -1193,9 +1201,42 @@ class PrefixWrapper(object): wrapped = property(_get_wrapped) - ##@property - ##def ident(self): - ## return self._prefix + _ident = False + + @property + def ident(self): + value = self._ident + if value is False: + value = None + # XXX: how will this interact with orig_prefix ? + # not exposing attrs for now if orig_prefix is set. + if not self.orig_prefix: + wrapped = self.wrapped + ident = getattr(wrapped, "ident", None) + if ident is not None: + value = self._wrap_hash(ident) + self._ident = value + return value + + _ident_values = False + @property + def ident_values(self): + value = self._ident_values + if value is False: + value = None + # XXX: how will this interact with orig_prefix ? + # not exposing attrs for now if orig_prefix is set. + if not self.orig_prefix: + wrapped = self.wrapped + idents = getattr(wrapped, "ident_values", None) + if idents: + value = [ self._wrap_hash(ident) for ident in idents ] + ##else: + ## ident = self.ident + ## if ident is not None: + ## value = [ident] + self._ident_values = value + return value #attrs that should be proxied _proxy_attrs = ( @@ -1209,10 +1250,20 @@ class PrefixWrapper(object): if self.prefix: args.append("prefix=%r" % self.prefix) if self.orig_prefix: - args.append("orig_prefix=%r", self.orig_prefix) + args.append("orig_prefix=%r" % self.orig_prefix) args = ", ".join(args) return 'PrefixWrapper(%r, %s)' % (self.name, args) + def __dir__(self): + attrs = set(dir(self.__class__)) + attrs.update(self.__dict__) + wrapped = self.wrapped + attrs.update( + attr for attr in self._proxy_attrs + if hasattr(wrapped, attr) + ) + return list(attrs) + def __getattr__(self, attr): "proxy most attributes from wrapped class (eg rounds, salt size, etc)" if attr in self._proxy_attrs: |
