summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2012-01-02 14:18:30 -0500
committerEli Collins <elic@assurancetechnologies.com>2012-01-02 14:18:30 -0500
commitb6b686f395ce9ea9ec0b56c9d5534d1e68409a1d (patch)
treeccccbee07b60bc8f221b4697585b6359c76f9775
parent333c8a1466ff596abb8c1666cc35915ac945ab8f (diff)
downloadpasslib-b6b686f395ce9ea9ec0b56c9d5534d1e68409a1d.tar.gz
CryptContext can now run passwords through SASLPrep via "passprep" options [issue 24]
-rw-r--r--CHANGES5
-rw-r--r--docs/lib/passlib.context-options.rst31
-rw-r--r--passlib/apps.py5
-rw-r--r--passlib/context.py62
-rw-r--r--passlib/tests/test_context.py58
5 files changed, 158 insertions, 3 deletions
diff --git a/CHANGES b/CHANGES
index 1cebb0e..ea3325c 100644
--- a/CHANGES
+++ b/CHANGES
@@ -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
#=========================================================