diff options
| author | Eli Collins <elic@assurancetechnologies.com> | 2011-12-22 20:03:00 -0500 |
|---|---|---|
| committer | Eli Collins <elic@assurancetechnologies.com> | 2011-12-22 20:03:00 -0500 |
| commit | ef5e536b7983c2180311ce2996796ed9f483650c (patch) | |
| tree | 3aba394d55632bdded97298eecee6a35a1366e45 | |
| parent | 0e5c0ff9648ffd16b9a333fb4c113f3e7831e3f1 (diff) | |
| download | passlib-ef5e536b7983c2180311ce2996796ed9f483650c.tar.gz | |
large rewrite of how CryptPolicy is parsed and compiled; should result in *much* shorter codepath when calling CryptContext.encrypt(), etc
| -rw-r--r-- | CHANGES | 5 | ||||
| -rw-r--r-- | docs/lib/passlib.utils.rst | 6 | ||||
| -rw-r--r-- | passlib/context.py | 935 | ||||
| -rw-r--r-- | passlib/registry.py | 37 | ||||
| -rw-r--r-- | passlib/tests/test_context.py | 323 | ||||
| -rw-r--r-- | passlib/utils/__init__.py | 22 |
6 files changed, 887 insertions, 441 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/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/context.py b/passlib/context.py index adcf467..7a9fd23 100644 --- a/passlib/context.py +++ b/passlib/context.py @@ -14,10 +14,11 @@ else: 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 time import os +import re from warnings import warn #site try: @@ -26,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.registry import get_crypt_handler, _validate_handler_name from passlib.utils import to_bytes, to_unicode, bytes, Undef, \ - is_crypt_handler, splitcomma, rng + is_crypt_handler, rng, \ + PasslibPolicyWarning #pkg #local __all__ = [ @@ -64,7 +66,9 @@ _context_comma_options = frozenset([ "schemes", "deprecated" ]) 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 '.') + if '.' not in key and '__' in key: + # this lets user specify kwds in python using '__' as separator, + # since python doesn't allow '.' in identifiers. key = key.replace("__", ".") parts = key.split(".") if len(parts) == 1: @@ -77,49 +81,21 @@ def _parse_policy_key(key): elif len(parts) == 3: cat, name, opt = parts else: - raise KeyError("keys must have 0..2 separators: %r" % (orig,)) + raise KeyError("keys must have less than 3 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" - # py2k # - if hasattr(source, "iteritems"): - source = source.iteritems() - # py3k # - #if hasattr(source, "items"): - # source = source.items() - # end py3k # - 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 @@ -289,6 +265,8 @@ class CryptPolicy(object): return cls.from_source(sources[0]) #else, build up list of kwds by parsing each source + # TODO: could probably replace this with some code that just merges _options + # and then calls _rebuild() on the final policy. kwds = {} for source in sources: policy = cls.from_source(source) @@ -300,7 +278,7 @@ class CryptPolicy(object): 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. @@ -320,79 +298,130 @@ class CryptPolicy(object): #========================================================= #instance attrs #========================================================= - #NOTE: all category dictionaries below will have a minimum of 'None' as a key + #: triply-nested dict mapping category -> scheme -> key -> value. + #: this is the internal representation of the original constructor options, + #: and is used when serializing. + _options = 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 will always be `None` + _categories = None - #:dict mapping category -> default handler for that category - _default = None + #: list of all handlers specified by `context.schemes` + _handlers = None - #:dict mapping category -> set of handler names which are deprecated for that category + #: dict mapping category -> names of deprecated handlers _deprecated = None - #:dict mapping category -> min verify time + #: 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 - - #:dict mapping (handler name, category) -> dict derived from options. - # this is used to cache results of the get_option() method - _cache = None + #: dict mapping (scheme, category) -> _PolicyRecord instance. + #: each _PolicyRecord encodes the final composite set of options + #: to be used for that (scheme, category) combination. + #: (None, category) will point to the default record for a given category. + _records = None #========================================================= #init #========================================================= def __init__(self, **kwds): self._from_dict(kwds) + self._rebuild() - #========================================================= - #internal init helpers - #========================================================= + #--------------------------------------------------------- + # load config from dict + #--------------------------------------------------------- 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} + "update :attr:`_options` from constructor keywords" + options = self._options = {None: {None: {}}} + validate = self._validate_option_key + normalize = self._normalize_option_value + + for full_key, value in kwds.iteritems(): + cat, scheme, key = _parse_policy_key(full_key) + validate(cat, scheme, key) + value = normalize(cat, scheme, key, value) + try: + config = options[cat] + except KeyError: + config = options[cat] = {} + try: + kwds = config[scheme] + except KeyError: + config[scheme] = {key: value} else: - config[opt] = value + kwds[key] = value + + self._categories = sorted(options) + assert self._categories[0] is None + + def _validate_option_key(self, cat, scheme, key): + "forbid certain (cat,scheme,key) combinations" + if scheme == "context": + if cat and key in _forbidden_category_context_options: + # e.g 'schemes' + raise KeyError("%r context option not allowed " + "per-category" % (key,)) + elif key in _forbidden_hash_options: + # e.g. 'salt' + raise KeyError("Passlib does not permit %r handler option " + "to be set via a policy object" % (key,)) + + def _normalize_option_value(self, cat, scheme, key, value): + "normalize option value types" + if scheme == "context": + # 'schemes', 'deprecated' may be passed in as comma-separated + # lists, need to be split apart into list of strings. + if key in _context_comma_options: + if isinstance(value, str): + value = _splitcomma(value) + + # this should be a float value (number of seconds) + elif key == "min_verify_time": + value = float(value) + + # if default specified as handler, convert to name. + # handler will be found via context.schemes + elif key == "default": + if hasattr(value, "name"): + value = value.name + + else: + # for hash options, try to coerce everything to an int, + # since most things are (e.g. the `*_rounds` options). + if value is not None: + try: + value = int(value) + except ValueError: + pass + + return value + + #--------------------------------------------------------- + # rebuild policy + #--------------------------------------------------------- + def _rebuild(self): + "(re)build internal caches from :attr:`_options`" # - #parse list of schemes, and resolve to handlers. + # build list of handlers # - schemes = context_options.get("schemes") or [] + get_option_value = self._get_option_value handlers = self._handlers = [] handler_names = set() - for scheme in schemes: + for input in (get_option_value(None, "context", "schemes") or []): #resolve & validate handler - if is_crypt_handler(scheme): - handler = scheme + if hasattr(input, "name"): + handler = input + name = handler.name + _validate_handler_name(name) else: - handler = get_crypt_handler(scheme) - name = handler.name - if not name: - raise TypeError("handler lacks name: %r" % (handler,)) + handler = get_crypt_handler(input) + name = handler.name #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,)) #add to handler list @@ -400,49 +429,129 @@ class CryptPolicy(object): handler_names.add(name) # - #build _deprecated & _default maps + # build deprecated map, ensure names are valid # - dmap = self._deprecated = {} - fmap = self._default = {} - mvmap = self._min_verify_time = {} - for cat, config in options.iteritems(): - kwds = config.pop("context", None) - if not kwds: + dep_map = self._deprecated = {} + for cat in self._categories: + deplist = get_option_value(cat, "context", "deprecated") + if deplist is None: continue + if handlers: + for scheme in deplist: + if scheme not in handler_names: + raise KeyError("deprecated scheme not found " + "in policy: %r" % (scheme,)) + dep_map[cat] = deplist - #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) + # + # build records for all (scheme, category) combinations + # + records = self._records = {} + if handlers: + default_scheme = get_option_value(None, "context", "default") or \ + handlers[0].name + for cat in self._categories: + for handler in handlers: + scheme = handler.name + kwds, has_cat_options = self._get_handler_options(scheme, + cat) + if cat and not has_cat_options: + # just re-use record from default category + records[scheme, cat] = records[scheme, None] + else: + records[scheme, cat] = _PolicyRecord(handler, cat, + **kwds) + if cat: + scheme = get_option_value(cat, "context", "default") or \ + default_scheme 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 + scheme = default_scheme + if scheme not in handler_names: + raise KeyError("default scheme not found in policy: %r" % + (scheme,)) + records[None, cat] = records[scheme, cat] + + # + # build min verify time map + # + mvt_map = self._min_verify_time = {} + for cat in self._categories: + value = get_option_value(cat, "context", "min_verify_time") + if value is None: + continue + if value < 0: + raise ValueError("min_verify_time must be >= 0") + mvt_map[cat] = value + + #========================================================= + # private helpers for reading :attr:`_options` + #========================================================= + def _get_option_value(self, category, scheme, key, default=None): + "get value from nested options dict" + try: + return self._options[category][scheme][key] + except KeyError: + return default + + def _get_option_kwds(self, category, scheme, default=None): + "get all kwds for specified category & scheme " + try: + return self._options[category][scheme] + except KeyError: + return default + + def _get_handler_options(self, scheme, category): + "return composite dict of handler options for given scheme + category" + options = self._options + has_cat_options = False + + # start with global.all kwds + global_config = options[None] + kwds = global_config.get("all") + if kwds: + kwds = kwds.copy() + else: + kwds = {} + + # add category.all kwds + if category and category in options: + config = options[category] + tmp = config.get("all") + if tmp: + kwds.update(tmp) + has_cat_options = True + else: + config = None + + # add global.scheme kwds + tmp = global_config.get(scheme) + if tmp: + kwds.update(tmp) + + # add category.scheme kwds + if config: + tmp = config.get(scheme) + if tmp: + kwds.update(tmp) + has_cat_options = True + + # add deprecated flag + deplist = self._deprecated.get(None) + dep = (deplist is not None and scheme in deplist) + if category: + deplist = self._deprecated.get(category) + if deplist is not None: + default_dep = dep + dep = (scheme in deplist) + if default_dep ^ dep: + has_cat_options = True + if dep: + kwds['deprecated'] = True + + return kwds, has_cat_options #========================================================= - #public interface (used by CryptContext) + # public interface (used by CryptContext) #========================================================= def has_schemes(self): "check if policy supported *any* schemes; returns True/False" @@ -459,6 +568,32 @@ class CryptPolicy(object): else: return [h.name for h in self._handlers] + def _get_record(self, name, category=None, required=True): + "private helper used by CryptContext" + # NOTE: this is speed-critical since it's called a lot by CryptContext + try: + return self._records[name, category] + except KeyError: + pass + if category: + # category not referenced in policy file. + # so populate cache from default category. + cache = self._records + try: + record = cache[name, None] + except KeyError: + pass + else: + cache[name, category] = record + return record + if not required: + return None + elif name: + raise KeyError("crypt algorithm not found in policy: %r" % (name,)) + else: + assert not self._handlers + raise KeyError("no crypt algorithms found in policy") + def get_handler(self, name=None, category=None, required=False): """given the name of a scheme, return handler which manages it. @@ -471,24 +606,12 @@ class CryptPolicy(object): :returns: handler attached to specified name or None """ - if name: - for handler in self._handlers: - if handler.name == name: - return handler + record = self._get_record(name, category, required) + if record: + return record.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 required: - raise KeyError("no crypt algorithm by that name: %r" % (name,)) - return None + assert not required + return None def get_options(self, name, category=None): """return dict of options for specified scheme @@ -500,65 +623,26 @@ class CryptPolicy(object): """ 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" 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 + deplist = self._deprecated.get(category) + if deplist is None and category: + deplist = self._deprecated.get(None) + return deplist is not None and name in deplist 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 + # NOTE: this is speed-critical since it's called a lot by CryptContext + try: + return self._min_verify_time[category] + except KeyError: + value = self._min_verify_time[category] = \ + self.get_min_verify_time(None) if category else 0 + return value #========================================================= #serialization @@ -569,7 +653,7 @@ class CryptPolicy(object): :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 @@ -589,7 +673,7 @@ class CryptPolicy(object): def encode_hlist(hl): return ", ".join(h.name for h in hl) def encode_nlist(hl): - return ", ".join(name for name in hl) + return ", ".join(hl) else: fmt1 = "%s__%s__%s" fmt2 = "%s__%s" @@ -614,25 +698,40 @@ class CryptPolicy(object): # #run through contents of internal configuration # + + # write list of handlers at start value = self._handlers if value: yield format_key(None, None, "schemes"), encode_hlist(value) - for cat, value in self._deprecated.iteritems(): - yield format_key(cat, None, "deprecated"), encode_nlist(value) - - for cat, value in self._default.iteritems(): - yield format_key(cat, None, "default"), encode_handler(value) - - for cat, value in self._min_verify_time.iteritems(): - yield format_key(cat, None, "min_verify_time"), value - - for cat, copts in self._options.iteritems(): - for name in sorted(copts): - config = copts[name] - for opt in sorted(config): - value = config[opt] - yield format_key(cat, name, opt), value + # then per-category elements + for cat in self._categories: + config = self._options[cat] + kwds = config.get("context") + if kwds: + # write deprecated list (if any) + value = kwds.get("deprecated") + if value is not None: + yield format_key(cat, None, "deprecated"), \ + encode_nlist(value) + + # write default declaration (if any) + value = kwds.get("default") + if value is not None: + yield format_key(cat, None, "default"), value + + # write mvt (if any) + value = kwds.get("min_verify_time") + if value is not None: + yield format_key(cat, None, "min_verify_time"), value + + # write configs for all schemes + for scheme in sorted(config): + if scheme == "context": + continue + kwds = config[scheme] + 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" @@ -641,7 +740,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, long)): + elif isinstance(v, (int, long, float)): v = str(v) return k,v @@ -687,6 +786,292 @@ class CryptPolicy(object): #eoc #========================================================= +class _PolicyRecord(object): + """wraps a handler and automatically applies various options + + this is a helper used internally by CryptPolicy and CryptContext + in order to reduce the amount of work that needs to be done when + CryptContext.verify() et al are called. + """ + + #================================================================ + # instance attrs + #================================================================ + + # informational attrs + handler = None # handler instance this is wrapping + category = None # user category this applies to + options = None # dict of all applicable options from policy (treat as RO) + deprecated = False # indicates if policy deprecated whole scheme + _ident = None # string used to identify record in error messages + + # attrs used by settings / hash generation + _settings = None # subset of options to be used as encrypt() defaults. + _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 + + # attrs used by deprecation handling + _has_rounds_introspection = False + + # cloned from handler + identify = None + genhash = None + verify = None + + #================================================================ + # init + #================================================================ + def __init__(self, handler, category=None, deprecated=False, **options): + self.handler = handler + self.category = category + self.options = options + self.deprecated = deprecated + if category: + self._ident = "%s %s policy" % (handler.name, category) + else: + self._ident = "%s policy" % (handler.name,) + self._compile_settings(options) + self._compile_deprecation(options) + + # these aren't modified by the record, so just copy them directly + self.identify = handler.identify + self.genhash = handler.genhash + self.verify = handler.verify + + #================================================================ + # config generation & helpers + #================================================================ + def _compile_settings(self, options): + handler = self.handler + self._settings = dict((k,v) for k,v in options.iteritems() + if k in handler.setting_kwds) + + if 'rounds' in handler.setting_kwds: + self._compile_rounds_settings(options) + + 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, clipping value: %d" % + (self._ident, mn, rounds), PasslibPolicyWarning) + rounds = mn + mx = self._max_rounds + if mx and rounds > mx: + warn("%s requires rounds <= %d, clipping value: %d" % + (self._ident, mx, rounds), PasslibPolicyWarning) + rounds = mx + kwds['rounds'] = rounds + + def _compile_rounds_settings(self, options): + "parse options and compile efficient generate_rounds function" + + handler = self.handler + 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 + #---------------------------------------------------- + mn = options.get("min_rounds") + mx = options.get("max_rounds") + df = options.get("default_rounds") + vr = options.get("vary_rounds") + + 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 df is None: + df = 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") + + 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 vr and df is None: + # fallback to handler's default if available + df = getattr(handler, "default_rounds", None) + + #---------------------------------------------------- + # 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 + + #================================================================ + # deprecation helpers + #================================================================ + def _compile_deprecation(self, options): + if self.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, **self.options): + 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 + #================================================================ + #========================================================= #load default policy from default.cfg #========================================================= @@ -802,76 +1187,6 @@ class CryptContext(object): #=================================================================== #policy adaptation #=================================================================== - 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) - 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 - - #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] - - #handle rounds - if 'rounds' in handler.setting_kwds: - self._prepare_rounds(handler, opts, settings) - - #done - return settings - def hash_needs_update(self, hash, category=None): """check if hash is allowed by current policy, or if secret should be re-encrypted. @@ -888,39 +1203,10 @@ class CryptContext(object): :returns: True/False """ - handler = self.identify(hash, resolve=True, required=True) - policy = self.policy - - #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 + # XXX: add scheme kwd for compatibility w/ other methods? + scheme = self.identify(hash, required=True) + record = self.policy._get_record(scheme, category) + return record.hash_needs_update(hash) #=================================================================== #password hash api proxy methods @@ -936,9 +1222,8 @@ 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) + record = self.policy._get_record(scheme, category, True) + return record.genconfig(**settings) def genhash(self, secret, config, scheme=None, category=None, **context): """Call genhash() for specified handler. @@ -1009,10 +1294,9 @@ 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) + record = self.policy._get_record(scheme, category, True) + return record.encrypt(secret, **kwds) def verify(self, secret, hash, scheme=None, category=None, **context): """verify secret against specified hash. @@ -1117,11 +1401,14 @@ class CryptContext(object): .. seealso:: :ref:`context-migrating-passwords` for a usage example. """ - ok = self.verify(secret, hash, scheme=scheme, category=category, **kwds) + if not scheme: + scheme = self.identify(hash, required=True) + ok = self.verify(secret, hash, scheme, category, **kwds) if not ok: return False, None - if self.hash_needs_update(hash, category=category): - return True, self.encrypt(secret, category=category, **kwds) + record = self.policy._get_record(scheme, category) + if record.hash_needs_update(hash): + return True, self.encrypt(secret, None, category, **kwds) else: return True, None 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 2d461d2..623d000 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 import passlib.utils.handlers as uh from passlib.tests.utils import TestCase, mktemp, catch_warnings, \ gae_env, set_file @@ -87,7 +87,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, @@ -200,16 +200,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 + #check key with too many separators is rejected self.assertRaises(KeyError, 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]) @@ -451,7 +451,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) @@ -468,10 +480,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 #========================================================= @@ -576,11 +584,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', @@ -590,73 +602,188 @@ 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=None) + self.assertWarningMatches(wlog.pop(), category=PasslibPolicyWarning) + self.assertEqual(c2.genconfig(salt="nacl"), "$5$rounds=1000$nacl$") + self.assertFalse(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.assertFalse(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.assertFalse(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.assertFalse(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 xrange(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=None) + self.assertWarningMatches(wlog.pop(), category=PasslibPolicyWarning) + self.assertEqual(c2.genconfig(salt="nacl"), + "$5$rounds=999999999$nacl$") + self.assertFalse(wlog) + + # above + self.assertEqual( + cc.genconfig(rounds=3001, salt="nacl"), + '$5$rounds=3000$nacl$' + ) + self.assertWarningMatches(wlog.pop(), category=PasslibPolicyWarning) + self.assertFalse(wlog) + + # equal + self.assertEqual( + cc.genconfig(rounds=3000, salt="nacl"), + '$5$rounds=3000$nacl$' + ) + self.assertFalse(wlog) - # default+vary % rounds - # this runs enough times the min and max *should* be hit, + # below + self.assertEqual( + cc.genconfig(rounds=2999, salt="nacl"), + '$5$rounds=2999$nacl$', + ) + self.assertFalse(wlog) + + # explicit default rounds + self.assertEqual(cc.genconfig(salt="nacl"), '$5$rounds=2500$nacl$') + + # implicit default rounds - use max + c2 = cc.replace(all__default_rounds=None) + self.assertEqual(c2.genconfig(salt="nacl"), '$5$rounds=3000$nacl$') + + # implicit default rounds - use min + c2 = c2.replace(all__max_rounds=None) + self.assertEqual(c2.genconfig(salt="nacl"), '$5$rounds=2000$nacl$') + + #default rounds - out of bounds + self.assertRaises(ValueError, cc.policy.replace, all__default_rounds=1999) + cc.policy.replace(all__default_rounds=2000) + cc.policy.replace(all__default_rounds=3000) + self.assertRaises(ValueError, cc.policy.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 xrange(30*50): - h = cc4.genconfig(salt="nacl") - r = sc.from_string(h).rounds + for i in xrange(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" @@ -672,33 +799,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" @@ -706,14 +829,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')) #========================================================= @@ -869,19 +992,19 @@ class CryptContextTest(TestCase): "teset verify_and_update / hash_needs_update corrects bcrypt padding" # see issue 25. bcrypt = hash.bcrypt - + PASS1 = "loppux" BAD1 = "$2a$12$oaQbBqq8JnSM1NHRPQGXORm4GCUMqp7meTnkft4zgSnrbhoKdDV0C" GOOD1 = "$2a$12$oaQbBqq8JnSM1NHRPQGXOOm4GCUMqp7meTnkft4zgSnrbhoKdDV0C" ctx = CryptContext(["bcrypt"]) - + with catch_warnings(record=True) as wlog: warnings.simplefilter("always") self.assertTrue(ctx.hash_needs_update(BAD1)) self.assertFalse(ctx.hash_needs_update(GOOD1)) - - if bcrypt.has_backend(): + + if bcrypt.has_backend(): self.assertEquals(ctx.verify_and_update(PASS1,GOOD1), (True,None)) self.assertEquals(ctx.verify_and_update("x",BAD1), (False,None)) res = ctx.verify_and_update(PASS1, BAD1) diff --git a/passlib/utils/__init__.py b/passlib/utils/__init__.py index 204bffd..d14ca5f 100644 --- a/passlib/utils/__init__.py +++ b/passlib/utils/__init__.py @@ -130,6 +130,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() #========================================================== @@ -536,7 +550,13 @@ 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) |
