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