diff options
author | Eli Collins <elic@assurancetechnologies.com> | 2012-01-02 14:18:30 -0500 |
---|---|---|
committer | Eli Collins <elic@assurancetechnologies.com> | 2012-01-02 14:18:30 -0500 |
commit | b6b686f395ce9ea9ec0b56c9d5534d1e68409a1d (patch) | |
tree | ccccbee07b60bc8f221b4697585b6359c76f9775 | |
parent | 333c8a1466ff596abb8c1666cc35915ac945ab8f (diff) | |
download | passlib-b6b686f395ce9ea9ec0b56c9d5534d1e68409a1d.tar.gz |
CryptContext can now run passwords through SASLPrep via "passprep" options [issue 24]
-rw-r--r-- | CHANGES | 5 | ||||
-rw-r--r-- | docs/lib/passlib.context-options.rst | 31 | ||||
-rw-r--r-- | passlib/apps.py | 5 | ||||
-rw-r--r-- | passlib/context.py | 62 | ||||
-rw-r--r-- | passlib/tests/test_context.py | 58 |
5 files changed, 158 insertions, 3 deletions
@@ -10,6 +10,11 @@ Release History .. currentmodule:: passlib.context + * :class:`~CryptContext` now supports a :ref:`passprep` option, + which runs all passwords through :rfc:`SASLPrep <4013>` + in order to normalize their unicode representation before hashing + [issue 24]. + * Internals of :class:`CryptPolicy` have been re-written drastically. Should now be stricter (and more informative) about invalid values, and common :class:`CryptContext` diff --git a/docs/lib/passlib.context-options.rst b/docs/lib/passlib.context-options.rst index 629a2b1..9753e39 100644 --- a/docs/lib/passlib.context-options.rst +++ b/docs/lib/passlib.context-options.rst @@ -139,6 +139,37 @@ Within INI files, this may be specified using the alternate format :samp:`{hash} These are configurable per-context limits, they will be clipped by any hard limits set in the hash algorithm itself. +.. _passprep: + +:samp:`{hash}__passprep` + + Normalize unicode passwords before passing them to the underlying + hash algorithm. This is primarily useful if users are likely + to use non-ascii characters in their password (e.g. vowels characters + with accent marks), which unicode offers multiple representations for. + + This may be one of the following values: + + * ``"raw"`` - use all unicode inputs as-is (the default). + unnormalized unicode input may not verify against a hash + generated from normalized unicode input (or vice versa). + + * ``"saslprep"`` - run all passwords through the SASLPrep + unicode normalization algorithm (:rfc:`4013`) before hashing. + this is recommended for new deployments, particularly + in non-ascii environments. + + * ``"saslprep,raw"`` - compatibility mode: encryption of new passwords + will be run through SASLPrep; but verification will be done + against the SASLPrep *and* raw versions of the password. This allows + existing hashes that were generated from unnormalized input + to continue to work. + + .. note:: + + It is recommended to set this for all hashes via ``all__passprep``, + instead of settings it per algorithm. + :samp:`{hash}__{setting}` Any other option values, which match the name of a parameter listed diff --git a/passlib/apps.py b/passlib/apps.py index ecdb834..39da6c1 100644 --- a/passlib/apps.py +++ b/passlib/apps.py @@ -29,6 +29,11 @@ custom_app_context = LazyCryptContext( #choose some reasonbly strong schemes schemes=["sha512_crypt", "sha256_crypt"], + # TODO: enable passprep for default policy? would definitely be a good + # idea for most applications; but want passprep to get a release or + # two worth of deployment & feedback before turning it on here. + ## all__passprep = "saslprep,raw", + #set some useful global options all__vary_rounds = "10%", default="sha256_crypt" if sys_bits < 64 else "sha512_crypt", diff --git a/passlib/context.py b/passlib/context.py index 9454b77..1d81b12 100644 --- a/passlib/context.py +++ b/passlib/context.py @@ -4,6 +4,7 @@ #========================================================= from __future__ import with_statement #core +from functools import update_wrapper import inspect import re import hashlib @@ -23,7 +24,7 @@ except ImportError: from passlib.registry import get_crypt_handler, _validate_handler_name from passlib.utils import to_bytes, to_unicode, bytes, \ is_crypt_handler, rng, \ - PasslibPolicyWarning, timer + PasslibPolicyWarning, timer, saslprep from passlib.utils.compat import is_mapping, iteritems, num_types, \ PY3, PY_MIN_32, unicode, bytes from passlib.utils.compat.aliases import SafeConfigParser, StringIO, BytesIO @@ -759,6 +760,11 @@ default_policy = _load_default_policy() #========================================================= # helpers for CryptContext #========================================================= +_passprep_funcs = dict( + saslprep=saslprep, + raw=lambda s: s, +) + class _CryptRecord(object): """wraps a handler and automatically applies various options. @@ -801,7 +807,7 @@ class _CryptRecord(object): #================================================================ def __init__(self, handler, category=None, deprecated=False, min_rounds=None, max_rounds=None, default_rounds=None, - vary_rounds=None, min_verify_time=None, + vary_rounds=None, min_verify_time=None, passprep=None, **settings): self.handler = handler self.category = category @@ -815,6 +821,9 @@ class _CryptRecord(object): self.identify = handler.identify self.genhash = handler.genhash + # let stringprep code wrap genhash/encrypt if needed + self._compile_passprep(passprep) + @property def scheme(self): return self.handler.name @@ -1082,6 +1091,55 @@ class _CryptRecord(object): _hash_needs_update = None #================================================================ + # password stringprep + #================================================================ + def _compile_passprep(self, value): + # NOTE: all of this code assumes secret uses utf-8 encoding if bytes. + if not value: + return + self._stringprep = value + names = _splitcomma(value) + if names == ["raw"]: + return + funcs = [_passprep_funcs[name] for name in names] + + first = funcs[0] + def wrap(orig): + def wrapper(secret, *args, **kwds): + if isinstance(secret, bytes): + secret = secret.decode("utf-8") + return orig(first(secret), *args, **kwds) + update_wrapper(wrapper, orig) + wrapper._wrapped = orig + return wrapper + + # wrap genhash & encrypt so secret is prep'd + self.genhash = wrap(self.genhash) + self.encrypt = wrap(self.encrypt) + + # wrap verify so secret is prep'd + if len(funcs) == 1: + self.verify = wrap(self.verify) + else: + # if multiple fallback prep functions, + # try to verify with each of them. + verify = self.verify + def wrapper(secret, *args, **kwds): + if isinstance(secret, bytes): + secret = secret.decode("utf-8") + seen = set() + for prep in funcs: + tmp = prep(secret) + if tmp not in seen: + if verify(tmp, *args, **kwds): + return True + seen.add(tmp) + return False + update_wrapper(wrapper, verify) + wrapper._wrapped = verify + self.verify = wrapper + + #================================================================ # eoc #================================================================ diff --git a/passlib/tests/test_context.py b/passlib/tests/test_context.py index 712f92c..25d1d40 100644 --- a/passlib/tests/test_context.py +++ b/passlib/tests/test_context.py @@ -19,7 +19,7 @@ except ImportError: from passlib import hash from passlib.context import CryptContext, CryptPolicy, LazyCryptContext from passlib.utils import to_bytes, to_unicode, PasslibPolicyWarning -from passlib.utils.compat import irange +from passlib.utils.compat import irange, u import passlib.utils.handlers as uh from passlib.tests.utils import TestCase, mktemp, catch_warnings, \ gae_env, set_file @@ -1015,6 +1015,62 @@ class CryptContextTest(TestCase): res = ctx.verify_and_update(PASS1, BAD1) self.assertTrue(res[0] and res[1] and res[1] != BAD1) + def test_91_passprep(self): + "test passprep option" + # saslprep should normalize pu -> pn + pu = u("a\u0300") # unnormalized unicode + pn = u("\u00E0") # normalized unicode + + # create contexts w/ various options + craw = CryptContext(["md5_crypt"]) + cnorm = CryptContext(["md5_crypt"], all__passprep="saslprep") + cback = CryptContext(["md5_crypt"], all__passprep="saslprep,raw") + clst = [craw,cnorm,cback] + + # check raw encrypt against verify methods + h = craw.encrypt(pu) + + self.assertTrue(craw.verify(pu, h)) + self.assertFalse(cnorm.verify(pu, h)) + self.assertTrue(cback.verify(pu, h)) + + self.assertFalse(craw.verify(pn, h)) + self.assertFalse(craw.verify(pn, h)) + self.assertFalse(craw.verify(pn, h)) + + # check normalized encrypt against verify methods + for ctx in [cnorm, cback]: + h = ctx.encrypt(pu) + + self.assertFalse(craw.verify(pu, h)) + self.assertTrue(cnorm.verify(pu, h)) + self.assertTrue(cback.verify(pu, h)) + + for ctx2 in clst: + self.assertTrue(ctx2.verify(pn, h)) + + # check all encrypts leave normalized input alone + for ctx in clst: + h = ctx.encrypt(pn) + + self.assertFalse(craw.verify(pu, h)) + self.assertTrue(cnorm.verify(pu, h)) + self.assertTrue(cback.verify(pu, h)) + + for ctx2 in clst: + self.assertTrue(ctx2.verify(pn, h)) + + # test invalid name + self.assertRaises(KeyError, CryptContext, ["md5_crypt"], + all__passprep="xxx") + + # test per-hash passprep + ctx = CryptContext(["md5_crypt", "sha256_crypt"], + all__passprep="raw", sha256_crypt__passprep="saslprep", + ) + self.assertFalse(ctx.verify(pu, ctx.encrypt(pn, scheme="md5_crypt"))) + self.assertTrue(ctx.verify(pu, ctx.encrypt(pn, scheme="sha256_crypt"))) + #========================================================= #eoc #========================================================= |