summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2011-12-22 20:03:00 -0500
committerEli Collins <elic@assurancetechnologies.com>2011-12-22 20:03:00 -0500
commitef5e536b7983c2180311ce2996796ed9f483650c (patch)
tree3aba394d55632bdded97298eecee6a35a1366e45
parent0e5c0ff9648ffd16b9a333fb4c113f3e7831e3f1 (diff)
downloadpasslib-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--CHANGES5
-rw-r--r--docs/lib/passlib.utils.rst6
-rw-r--r--passlib/context.py935
-rw-r--r--passlib/registry.py37
-rw-r--r--passlib/tests/test_context.py323
-rw-r--r--passlib/utils/__init__.py22
6 files changed, 887 insertions, 441 deletions
diff --git a/CHANGES b/CHANGES
index 7fb8c2c..b363da6 100644
--- a/CHANGES
+++ b/CHANGES
@@ -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)