diff options
| author | Eli Collins <elic@assurancetechnologies.com> | 2011-05-03 15:14:45 -0400 |
|---|---|---|
| committer | Eli Collins <elic@assurancetechnologies.com> | 2011-05-03 15:14:45 -0400 |
| commit | f58865b964e4df6f98bc93700f9ea9dc596cca17 (patch) | |
| tree | ae42228660d1befb53bd9bd320935420948f65dd | |
| parent | 80f60261f698fe3d147bd9b0ff2ff3e0f1cef732 (diff) | |
| download | passlib-f58865b964e4df6f98bc93700f9ea9dc596cca17.tar.gz | |
tightened salt info specifications; improved salt info conformance tests
| -rw-r--r-- | CHANGES | 8 | ||||
| -rw-r--r-- | docs/lib/passlib.utils.rst | 4 | ||||
| -rw-r--r-- | docs/password_hash_api.rst | 72 | ||||
| -rw-r--r-- | passlib/handlers/md5_crypt.py | 4 | ||||
| -rw-r--r-- | passlib/handlers/sha2_crypt.py | 4 | ||||
| -rw-r--r-- | passlib/handlers/sun_md5_crypt.py | 12 | ||||
| -rw-r--r-- | passlib/tests/test_utils_handlers.py | 32 | ||||
| -rw-r--r-- | passlib/tests/utils.py | 165 | ||||
| -rw-r--r-- | passlib/utils/__init__.py | 10 | ||||
| -rw-r--r-- | passlib/utils/handlers.py | 2 |
10 files changed, 211 insertions, 102 deletions
@@ -18,8 +18,7 @@ Release History * added support for all hashes used by the Roundup Issue Tracker * bsdi_crypt, sha1_crypt now check for OS crypt() support * ``salt_size`` keyword added to encrypt() method of all - the hashes which support variable-length salts - (cheifly: pbkdf2_{digest}, sha1_crypt, grub_pbkdf2_sha512). + the hashes which support variable-length salts. * security fix: disabled unix_fallback's "wildcard password" support unless explicitly enabled by user. @@ -60,6 +59,11 @@ Release History - renamed *salt_charset* -> *salt_chars* - old attributes still present, but deprecated - will remove in 1.5 + * password hash api - tightened specifications for salt & rounds parameters, + added support for hashes w/ no max salt size. + + * improved password hash api conformance tests + **1.3.1** (2011-03-28) * bugfix: replaced "sys.maxsize" reference that was failing under py25 diff --git a/docs/lib/passlib.utils.rst b/docs/lib/passlib.utils.rst index 370614f..1fa826f 100644 --- a/docs/lib/passlib.utils.rst +++ b/docs/lib/passlib.utils.rst @@ -54,6 +54,10 @@ Object Tests .. autofunction:: is_crypt_context +.. autofunction:: has_rounds_info + +.. autofunction:: has_salt_info + Submodules ========== There are also a few sub modules which provide additional utility functions: diff --git a/docs/password_hash_api.rst b/docs/password_hash_api.rst index 09c4505..609b8c2 100644 --- a/docs/password_hash_api.rst +++ b/docs/password_hash_api.rst @@ -1,6 +1,6 @@ .. index:: single: password hash api - pair: custom hash handler; requirements + single: custom hash handler; requirements .. currentmodule:: passlib.hash @@ -120,6 +120,12 @@ Required Attributes a specific salt string; though not only is this far from needed for most cases, the salt string's content constraints vary for each algorithm. + ``salt_size`` + If present, this means the algorithm will auto-generate a salt + of the specified number of characters + (assuming ``salt`` is not specified explicitly). + If omitted, most algorithms will fall back to a default salt size. + ``rounds`` If present, this means the algorithm allows for a variable number of rounds to be used, allowing the processor time required to be increased. @@ -361,28 +367,33 @@ across all handlers in passlib. Consider making these attributes required for all hashes which support the appropriate :attr:`settings` keyword. +.. _optional-rounds-attributes: + Rounds Information ------------------ For schemes which support a variable number of rounds (ie, ``'rounds' in PasswordHash.setting_kwds``), the following attributes are usually exposed. -(Applications can test for this suites' presence by checking if ``getattr(handler,"max_rounds",None)>0``) +(Applications can test for this suites' presence by using :func:`~passlib.utils.has_rounds_info`) -.. attribute:: PasswordHash.default_rounds +.. attribute:: PasswordHash.max_rounds - The default number of rounds that will be used if not - explicitly set when calling :meth:`~PasswordHash.encrypt` or :meth:`~PasswordHash.genconfig`. + The maximum number of rounds the scheme allows. + Specifying values above this will generally result + in a warning, and :attr:`~!PasswordHash.max_rounds` will be used instead. + Must be a positive integer. .. attribute:: PasswordHash.min_rounds The minimum number of rounds the scheme allows. Specifying values below this will generally result in a warning, and :attr:`~!PasswordHash.min_rounds` will be used instead. + Must be within ``range(0, max_rounds+1)``. -.. attribute:: PasswordHash.max_rounds +.. attribute:: PasswordHash.default_rounds - The maximum number of rounds the scheme allows. - Specifying values above this will generally result - in a warning, and :attr:`~!PasswordHash.max_rounds` will be used instead. + The default number of rounds that will be used if not + explicitly set when calling :meth:`~PasswordHash.encrypt` or :meth:`~PasswordHash.genconfig`. + Must be within ``range(min_rounds, max_rounds+1)``. .. attribute:: PasswordHash.rounds_cost @@ -395,24 +406,38 @@ the following attributes are usually exposed. ``log2`` time taken scales exponentially with rounds value (eg: :class:`~passlib.hash.bcrypt`) +.. _optional-salt-attributes: + Salt Information ---------------- For schemes which support a salt (ie, ``'salt' in PasswordHash.setting_kwds``), the following attributes are usually exposed. -(Applications can test for this suites' presence by checking if ``getattr(handler,"max_salt_size",None)>0``) +(Applications can test for this suites' presence by using :func:`~passlib.utils.has_salt_info`) .. attribute:: PasswordHash.max_salt_size - maximum number of characters which will be *used* + maximum number of characters which will be used if a salt string is provided to :meth:`~PasswordHash.genconfig` or :meth:`~PasswordHash.encrypt`. - must be positive integer if salts are supported, - may be ``None`` or ``0`` if salts are not supported. + must be one of: + + * A positive integer - it should accept and silently truncate + any salt strings longer than this size. + + * ``None`` - the scheme should use all characters of a provided salt, + no matter how large. .. attribute:: PasswordHash.min_salt_size minimum number of characters required in salt string, if provided to :meth:`~PasswordHash.genconfig` or :meth:`~PasswordHash.encrypt`. - must be non-negative integer that is not greater than :attr:`~PasswordHash.max_salt_size`. + must be an integer within ``range(0,max_salt_size+1)``. + +.. attribute:: PasswordHash.default_salt_size + + size of salts generated by genconfig + when no salt is provided by caller. + for most hashes, this defaults to :attr:`!PasswordHash.max_salt_size`. + this value must be within ``range(min_salt_size, max_salt_size+1)``. .. attribute:: PasswordHash.salt_chars @@ -431,21 +456,15 @@ the following attributes are usually exposed. .. todo:: this list the behavior for handlers which accept - an salt string containing characters. - some handlers take raw bytes - for their salt keyword, how these attributes change - in that situtation should be documentated. - + salt strings containing encoded characters. + some handlers instead take raw bytes for their salt keyword, + and handle encoding / decoding them internally. + it should be documented how these attributes + behave in that situation. .. not yet documentated, want to make sure this is how we want to do things: - .. attribute:: PasswordHash.default_salt_size - - size of salts generated by genconfig - when no salt is provided by caller. - for most hashes, this defaults to :attr:`!PasswordHash.max_salt_size`. - .. attribute:: PasswordHash.default_salt_chars sequence of characters used to generated new salts @@ -456,6 +475,9 @@ the following attributes are usually exposed. the full range to be accepted, while only a select subset to be used for generation. + xxx: what about a bits_per_salt_char or some such, so effective salt strength + can be compared? + Footnotes ========= .. [#otypes] While this specification is written referring to classes and classmethods, diff --git a/passlib/handlers/md5_crypt.py b/passlib/handlers/md5_crypt.py index 557f0a8..a4deb72 100644 --- a/passlib/handlers/md5_crypt.py +++ b/passlib/handlers/md5_crypt.py @@ -149,7 +149,7 @@ class md5_crypt(uh.HasManyBackends, uh.HasSalt, uh.GenericHandler): #========================================================= #--GenericHandler-- name = "md5_crypt" - setting_kwds = ("salt",) + setting_kwds = ("salt", "salt_size") ident = "$1$" checksum_size = 22 @@ -217,7 +217,7 @@ class apr_md5_crypt(uh.HasSalt, uh.GenericHandler): #========================================================= #--GenericHandler-- name = "apr_md5_crypt" - setting_kwds = ("salt",) + setting_kwds = ("salt", "salt_size") ident = "$apr1$" checksum_size = 22 diff --git a/passlib/handlers/sha2_crypt.py b/passlib/handlers/sha2_crypt.py index eb8f6b6..56e72e9 100644 --- a/passlib/handlers/sha2_crypt.py +++ b/passlib/handlers/sha2_crypt.py @@ -241,7 +241,7 @@ class sha256_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandl #========================================================= #--GenericHandler-- name = "sha256_crypt" - setting_kwds = ("salt", "rounds", "implicit_rounds") + setting_kwds = ("salt", "rounds", "implicit_rounds", "salt_size") ident = "$5$" #--HasSalt-- @@ -388,7 +388,7 @@ class sha512_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandl name = "sha512_crypt" ident = "$6$" - setting_kwds = ("salt", "rounds", "implicit_rounds") + setting_kwds = ("salt", "rounds", "implicit_rounds", "salt_size") min_salt_size = 0 max_salt_size = 16 diff --git a/passlib/handlers/sun_md5_crypt.py b/passlib/handlers/sun_md5_crypt.py index dfa5559..4306b5c 100644 --- a/passlib/handlers/sun_md5_crypt.py +++ b/passlib/handlers/sun_md5_crypt.py @@ -174,9 +174,14 @@ class sun_md5_crypt(uh.HasRounds, uh.HasSalt, uh.GenericHandler): :param salt: Optional salt string. - If not specified, an 8 character salt will be autogenerated (this is recommended). + If not specified, a salt will be autogenerated (this is recommended). If specified, it must be drawn from the regexp range ``[./0-9A-Za-z]``. + :param salt_size: + If no salt is specified, this parameter can be used to specify + the size (in characters) of the autogenerated salt. + It currently defaults to 8. + :param rounds: Optional number of rounds to use. Defaults to 5000, must be between 0 and 4294963199, inclusive. @@ -192,17 +197,16 @@ class sun_md5_crypt(uh.HasRounds, uh.HasSalt, uh.GenericHandler): #class attrs #========================================================= name = "sun_md5_crypt" - setting_kwds = ("salt", "rounds", "bare_salt") + setting_kwds = ("salt", "rounds", "bare_salt", "salt_size") #NOTE: docs say max password length is 255. #release 9u2 #NOTE: not sure if original crypt has a salt size limit, # all instances that have been seen use 8 chars. - # setting max+strict bounds just to keep inputs sane. default_salt_size = 8 min_salt_size = 0 - max_salt_size = 32 + max_salt_size = None salt_chars = uh.H64_CHARS default_rounds = 5000 #current passlib default diff --git a/passlib/tests/test_utils_handlers.py b/passlib/tests/test_utils_handlers.py index 54487ed..c4a8f01 100644 --- a/passlib/tests/test_utils_handlers.py +++ b/passlib/tests/test_utils_handlers.py @@ -101,7 +101,7 @@ class SkeletonTest(TestCase): self.assertRaises(ValueError, d1.norm_checksum, 'xxxxx') self.assertRaises(ValueError, d1.norm_checksum, 'xxyx') - def test_12_norm_salt(self): + def test_20_norm_salt(self): "test GenericHandler+HasSalt: .norm_salt(), .generate_salt()" class d1(uh.HasSalt, uh.GenericHandler): name = 'd1' @@ -129,7 +129,31 @@ class SkeletonTest(TestCase): self.assertEquals(len(d1.norm_salt(None,salt_size=5)), 3) self.assertRaises(ValueError, d1.norm_salt, None, salt_size=5, strict=True) - def test_13_norm_rounds(self): + def test_21_norm_salt(self): + "test GenericHandler+HasSalt: .norm_salt(), .generate_salt() - with no max_salt_size" + class d1(uh.HasSalt, uh.GenericHandler): + name = 'd1' + setting_kwds = ('salt',) + min_salt_size = 1 + max_salt_size = None + default_salt_size = 2 + salt_chars = 'a' + + #check salt=None + self.assertEqual(d1.norm_salt(None), 'aa') + self.assertRaises(ValueError, d1.norm_salt, None, strict=True) + + #check small & large salts + self.assertRaises(ValueError, d1.norm_salt, '') + self.assertEqual(d1.norm_salt('aaaa', strict=True), 'aaaa') + + #check generate salt (indirectly) + self.assertEquals(len(d1.norm_salt(None)), 2) + self.assertEquals(len(d1.norm_salt(None,salt_size=1)), 1) + self.assertEquals(len(d1.norm_salt(None,salt_size=3)), 3) + self.assertEquals(len(d1.norm_salt(None,salt_size=5)), 5) + + def test_30_norm_rounds(self): "test GenericHandler+HasRounds: .norm_rounds()" class d1(uh.HasRounds, uh.GenericHandler): name = 'd1' @@ -154,7 +178,7 @@ class SkeletonTest(TestCase): d1.default_rounds = None self.assertRaises(ValueError, d1.norm_rounds, None) - def test_14_backends(self): + def test_40_backends(self): "test GenericHandler+HasManyBackends" class d1(uh.HasManyBackends, uh.GenericHandler): name = 'd1' @@ -198,7 +222,7 @@ class SkeletonTest(TestCase): d1.set_backend('a') self.assertEquals(obj.calc_checksum('s'), 'a') - def test_15_bh_norm_ident(self): + def test_50_bh_norm_ident(self): "test GenericHandler+HasManyIdents: .norm_ident() & .identify()" class d1(uh.HasManyIdents, uh.GenericHandler): name = 'd1' diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py index f4c9269..20197c2 100644 --- a/passlib/tests/utils.py +++ b/passlib/tests/utils.py @@ -10,6 +10,7 @@ import os import tempfile import unittest import warnings +from warnings import warn try: from warnings import catch_warnings except ImportError: @@ -25,7 +26,9 @@ except ImportError: from nose.plugins.skip import SkipTest #pkg from passlib import registry -from passlib.utils import classproperty, handlers as uh +from passlib.utils import classproperty, handlers as uh, \ + has_rounds_info, has_salt_info, \ + rounds_cost_values #local __all__ = [ #util funcs @@ -305,14 +308,6 @@ class HandlerCase(TestCase): name += " (%s backend)" % (backend(),) return name - @classproperty - def has_salt_info(cls): - return 'salt' in cls.handler.setting_kwds and getattr(cls.handler, "max_salt_size", None) > 0 - - @classproperty - def has_rounds_info(cls): - return 'rounds' in cls.handler.setting_kwds and getattr(cls.handler, "max_rounds", None) > 0 - backend = "default" def setUp(self): @@ -348,51 +343,80 @@ class HandlerCase(TestCase): self.assert_(context is not None, "context_kwds must be defined:") self.assertIsInstance(context, tuple, "context_kwds must be a tuple:") - #TODO: check optional rounds attributes & salt attributes - - def test_05_ext_handler(self): - "check configuration of GenericHandler-derived classes" + def test_01_optional_salt_attributes(self): + "validate optional salt attributes" cls = self.handler - if not isinstance(cls, type) or not issubclass(cls, uh.GenericHandler): + if not has_salt_info(cls): raise SkipTest - if 'salt' in cls.setting_kwds: - # assume HasSalt / HasRawSalt - - if cls.min_salt_size > cls.max_salt_size: - raise AssertionError("min salt chars too large") - - if cls.default_salt_size < cls.min_salt_size: - raise AssertionError("default salt chars too small") - if cls.default_salt_size > cls.max_salt_size: - raise AssertionError("default salt chars too large") - - if cls.salt_chars: - if not cls.default_salt_chars: - raise AssertionError("default salt charset must not be empty") - if any(c not in cls.salt_chars for c in cls.default_salt_chars): - raise AssertionError("default salt charset not subset of salt charset: %r" % (c,)) - else: - if not cls.default_salt_chars: - raise AssertionError("default salt charset must be specified if salt_chars is empty") - - - if 'rounds' in cls.setting_kwds: - # assume uses HasRounds - if cls.max_rounds is None: - raise AssertionError("max rounds not specified") + #check max_salt_size + mx_set = (cls.max_salt_size is not None) + if mx_set and cls.max_salt_size < 1: + raise AssertionError("max_salt_chars must be >= 1") + + #check min_salt_size + if cls.min_salt_size < 0: + raise AssertionError("min_salt_chars must be >= 0") + if mx_set and cls.min_salt_size > cls.max_salt_size: + raise AssertionError("min_salt_chars must be <= max_salt_chars") + + #check default_salt_size + if cls.default_salt_size < cls.min_salt_size: + raise AssertionError("default_salt_size must be >= min_salt_size") + if mx_set and cls.default_salt_size > cls.max_salt_size: + raise AssertionError("default_salt_size must be <= max_salt_size") + + #check for 'salt_size' keyword + if 'salt_size' not in cls.setting_kwds and \ + (not mx_set or cls.min_salt_size < cls.max_salt_size): + #NOTE: for now, only bothering to issue warning if default_salt_size isn't maxed out + if (not mx_set or cls.default_salt_size < cls.max_salt_size): + warn("%s: hash handler supports range of salt sizes, but doesn't specify 'salt_size' setting" % (cls.name,)) + + #check salt_chars & default_salt_chars + if cls.salt_chars: + if not cls.default_salt_chars: + raise AssertionError("default_salt_chars must not be empty") + if any(c not in cls.salt_chars for c in cls.default_salt_chars): + raise AssertionError("default_salt_chars must be subset of salt_chars: %r not in salt_chars" % (c,)) + else: + if not cls.default_salt_chars: + raise AssertionError("default_salt_chars MUST be specified if salt_chars is empty") - if cls.min_rounds > cls.max_rounds: - raise AssertionError("min rounds too large") + def test_02_optional_rounds_attributes(self): + "validate optional rounds attributes" + cls = self.handler + if not has_rounds_info(cls): + raise SkipTest - if cls.default_rounds is not None: - if cls.default_rounds < cls.min_rounds: - raise AssertionError("default rounds too small") - if cls.default_rounds > cls.max_rounds: - raise AssertionError("default rounds too large") + #check max_rounds + if cls.max_rounds is None: + raise AssertionError("max_rounds not specified") + if cls.max_rounds < 1: + raise AssertionError("max_rounds must be >= 1") + + #check min_rounds + if cls.min_rounds < 0: + raise AssertionError("min_rounds must be >= 0") + if cls.min_rounds > cls.max_rounds: + raise AssertionError("min_rounds must be <= max_rounds") + + #check default_rounds + if cls.default_rounds is not None: + if cls.default_rounds < cls.min_rounds: + raise AssertionError("default_rounds must be >= min_rounds") + if cls.default_rounds > cls.max_rounds: + raise AssertionError("default_rounds must be <= max_rounds") + + #check rounds_cost + if cls.rounds_cost not in rounds_cost_values: + raise AssertionError("unknown rounds cost constant: %r" % (cls.rounds_cost,)) - if cls.rounds_cost not in ("linear", "log2"): - raise AssertionError("unknown rounds cost function") + def test_05_ext_handler(self): + "check configuration of GenericHandler-derived classes" + cls = self.handler + if not isinstance(cls, type) or not issubclass(cls, uh.GenericHandler): + raise SkipTest if 'ident' in cls.setting_kwds: # assume uses HasManyIdents @@ -528,9 +552,9 @@ class HandlerCase(TestCase): def test_31_genconfig_minsalt(self): "test genconfig() honors min salt chars" - if not self.has_salt_info: - raise SkipTest handler = self.handler + if not has_salt_info(handler): + raise SkipTest cs = handler.salt_chars mn = handler.min_salt_size c1 = self.do_genconfig(salt=cs[0] * mn) @@ -539,35 +563,52 @@ class HandlerCase(TestCase): def test_32_genconfig_maxsalt(self): "test genconfig() honors max salt chars" - if not self.has_salt_info: - raise SkipTest handler = self.handler + if not has_salt_info(handler): + raise SkipTest cs = handler.salt_chars mx = handler.max_salt_size - c1 = self.do_genconfig(salt=cs[0] * mx) - c2 = self.do_genconfig(salt=cs[0] * (mx+1)) - self.assertEquals(c1,c2) + if mx is None: + #make sure salt is NOT truncated, + #use a really large salt for testing + salt = cs[0] * 1024 + c1 = self.do_genconfig(salt=salt) + c2 = self.do_genconfig(salt=salt + cs[0]) + self.assertNotEqual(c1,c2) + else: + #make sure salt is truncated exactly where it should be. + salt = cs[0] * mx + c1 = self.do_genconfig(salt=salt) + c2 = self.do_genconfig(salt=salt + cs[0]) + self.assertEqual(c1,c2) + + #if min_salt supports it, check smaller than mx is NOT truncated + if handler.min_salt_size < mx: + c3 = self.do_genconfig(salt=salt[:-1]) + self.assertNotEqual(c1,c3) def test_33_genconfig_saltcharset(self): "test genconfig() honors salt charset" - if not self.has_salt_info: - raise SkipTest handler = self.handler + if not has_salt_info(handler): + raise SkipTest mx = handler.max_salt_size mn = handler.min_salt_size cs = handler.salt_chars #make sure all listed chars are accepted - for i in xrange(0,len(cs),mx): - salt = cs[i:i+mx] + chunk = 1024 if mx is None else mx + for i in xrange(0,len(cs),chunk): + salt = cs[i:i+chunk] if len(salt) < mn: - salt = (salt*(mn//len(salt)+1))[:mx] + salt = (salt*(mn//len(salt)+1))[:chunk] self.do_genconfig(salt=salt) - #check some invalid salt chars are rejected + #check some invalid salt chars, make sure they're rejected + chunk = mn if mn > 0 else 1 for c in '\x00\xff': if c not in cs: - self.assertRaises(ValueError, self.do_genconfig, salt=c*mx) + self.assertRaises(ValueError, self.do_genconfig, salt=c*chunk) #========================================================= #genhash() diff --git a/passlib/utils/__init__.py b/passlib/utils/__init__.py index 23365e3..9aa72b6 100644 --- a/passlib/utils/__init__.py +++ b/passlib/utils/__init__.py @@ -62,6 +62,8 @@ unix_crypt_schemes = [ "bsdi_crypt", "des_crypt" ] +#: list of rounds_cost constants +rounds_cost_values = [ "linear", "log2" ] #: special byte string containing all possible byte values, used in a few places. #XXX: treated as singleton by some of the code for efficiency. @@ -169,6 +171,14 @@ def is_crypt_context(obj): "verify", "encrypt", "identify", )) +def has_rounds_info(handler): + "check if handler provides the optional :ref:`rounds information <optional-rounds-attributes>` attributes" + return 'rounds' in handler.setting_kwds and getattr(handler, "min_rounds", None) is not None + +def has_salt_info(handler): + "check if handler provides the optional :ref:`salt information <optional-salt-attributes>` attributes" + return 'salt' in handler.setting_kwds and getattr(handler, "min_salt_size", None) is not None + #================================================================================= #string helpers #================================================================================= diff --git a/passlib/utils/handlers.py b/passlib/utils/handlers.py index fabe63a..aba9af5 100644 --- a/passlib/utils/handlers.py +++ b/passlib/utils/handlers.py @@ -1262,7 +1262,7 @@ class HasSalt(GenericHandler): #check max size mx = cls.max_salt_size - if len(salt) > mx: + if mx is not None and len(salt) > mx: if strict: raise ValueError("%s salt string must be at most %d characters" % (cls.name, mx)) salt = salt[:mx] |
