]> granicus.if.org Git - python/commitdiff
bpo-31702: Allow to specify rounds for SHA-2 hashing in crypt.mksalt(). (#4110)
authorSerhiy Storchaka <storchaka@gmail.com>
Thu, 16 Nov 2017 11:22:51 +0000 (13:22 +0200)
committerGitHub <noreply@github.com>
Thu, 16 Nov 2017 11:22:51 +0000 (13:22 +0200)
The log_rounds parameter for Blowfish has been replaced with the rounds parameter.

Doc/library/crypt.rst
Doc/whatsnew/3.7.rst
Lib/crypt.py
Lib/test/test_crypt.py
Misc/NEWS.d/next/Library/2017-10-24-21-10-44.bpo-31702.SfwJDI.rst [new file with mode: 0644]

index 9877b711a9af163dff42a2270d00b89ff2d367c7..dd62cb32b9d16bfa50f7c0039c183ff0030f0c81 100644 (file)
@@ -116,7 +116,7 @@ The :mod:`crypt` module defines the following functions:
       Accept ``crypt.METHOD_*`` values in addition to strings for *salt*.
 
 
-.. function:: mksalt(method=None, *, log_rounds=12)
+.. function:: mksalt(method=None, *, rounds=None)
 
    Return a randomly generated salt of the specified method.  If no
    *method* is given, the strongest method available as returned by
@@ -125,14 +125,18 @@ The :mod:`crypt` module defines the following functions:
    The return value is a string suitable for passing as the *salt* argument
    to :func:`crypt`.
 
-   *log_rounds* specifies the binary logarithm of the number of rounds
-   for ``crypt.METHOD_BLOWFISH``, and is ignored otherwise.  ``8`` specifies
-   ``256`` rounds.
+   *rounds* specifies the number of rounds for ``METHOD_SHA256``,
+   ``METHOD_SHA512`` and ``METHOD_BLOWFISH``.
+   For ``METHOD_SHA256`` and ``METHOD_SHA512`` it must be an integer between
+   ``1000`` and ``999_999_999``, the default is ``5000``.  For
+   ``METHOD_BLOWFISH`` it must be a power of two between ``16`` (2\ :sup:`4`)
+   and ``2_147_483_648`` (2\ :sup:`31`), the default is ``4096``
+   (2\ :sup:`12`).
 
    .. versionadded:: 3.3
 
    .. versionchanged:: 3.7
-      Added the *log_rounds* parameter.
+      Added the *rounds* parameter.
 
 
 Examples
index bb75939321d5d69b6fbbac13c5d33274c1ae71aa..71e8358a420b6142160737d7084685fb0683f8cd 100644 (file)
@@ -280,6 +280,9 @@ crypt
 Added support for the Blowfish method.
 (Contributed by Serhiy Storchaka in :issue:`31664`.)
 
+The :func:`~crypt.mksalt` function now allows to specify the number of rounds
+for hashing.  (Contributed by Serhiy Storchaka in :issue:`31702`.)
+
 dis
 ---
 
index 4d73202b468796ca791d4937e1e7adb5ce4ec960..b0e47f430c3cbc7a72c4ebfce30aed27967aa28b 100644 (file)
@@ -19,7 +19,7 @@ class _Method(_namedtuple('_Method', 'name ident salt_chars total_size')):
         return '<crypt.METHOD_{}>'.format(self.name)
 
 
-def mksalt(method=None, *, log_rounds=12):
+def mksalt(method=None, *, rounds=None):
     """Generate a salt for the specified method.
 
     If not specified, the strongest available method will be used.
@@ -27,12 +27,32 @@ def mksalt(method=None, *, log_rounds=12):
     """
     if method is None:
         method = methods[0]
-    if not method.ident:
+    if rounds is not None and not isinstance(rounds, int):
+        raise TypeError(f'{rounds.__class__.__name__} object cannot be '
+                        f'interpreted as an integer')
+    if not method.ident:  # traditional
         s = ''
-    elif method.ident[0] == '2':
-        s = f'${method.ident}${log_rounds:02d}$'
-    else:
+    else:  # modular
         s = f'${method.ident}$'
+
+    if method.ident and method.ident[0] == '2':  # Blowfish variants
+        if rounds is None:
+            log_rounds = 12
+        else:
+            log_rounds = int.bit_length(rounds-1)
+            if rounds != 1 << log_rounds:
+                raise ValueError('rounds must be a power of 2')
+            if not 4 <= log_rounds <= 31:
+                raise ValueError('rounds out of the range 2**4 to 2**31')
+        s += f'{log_rounds:02d}$'
+    elif method.ident in ('5', '6'):  # SHA-2
+        if rounds is not None:
+            if not 1000 <= rounds <= 999_999_999:
+                raise ValueError('rounds out of the range 1000 to 999_999_999')
+            s += f'rounds={rounds}$'
+    elif rounds is not None:
+        raise ValueError(f"{method} doesn't support the rounds argument")
+
     s += ''.join(_sr.choice(_saltchars) for char in range(method.salt_chars))
     return s
 
@@ -55,10 +75,10 @@ def crypt(word, salt=None):
 #  available salting/crypto methods
 methods = []
 
-def _add_method(name, *args):
+def _add_method(name, *args, rounds=None):
     method = _Method(name, *args)
     globals()['METHOD_' + name] = method
-    salt = mksalt(method, log_rounds=4)
+    salt = mksalt(method, rounds=rounds)
     result = crypt('', salt)
     if result and len(result) == method.total_size:
         methods.append(method)
@@ -74,7 +94,7 @@ _add_method('SHA256', '5', 16, 63)
 # 'y' is the same as 'b', for compatibility
 # with openwall crypt_blowfish.
 for _v in 'b', 'y', 'a', '':
-    if _add_method('BLOWFISH', '2' + _v, 22, 59 + len(_v)):
+    if _add_method('BLOWFISH', '2' + _v, 22, 59 + len(_v), rounds=1<<4):
         break
 
 _add_method('MD5', '1', 8, 34)
index 796fd076c6a5e5b144d1a8b0e3dc64f5ef3ca650..d9189fc6f28b473bde40bb9cd34bdf22c7225d41 100644 (file)
@@ -39,12 +39,26 @@ class CryptTestCase(unittest.TestCase):
         else:
             self.assertEqual(crypt.methods[-1], crypt.METHOD_CRYPT)
 
+    @unittest.skipUnless(crypt.METHOD_SHA256 in crypt.methods or
+                         crypt.METHOD_SHA512 in crypt.methods,
+                        'requires support of SHA-2')
+    def test_sha2_rounds(self):
+        for method in (crypt.METHOD_SHA256, crypt.METHOD_SHA512):
+            for rounds in 1000, 10_000, 100_000:
+                salt = crypt.mksalt(method, rounds=rounds)
+                self.assertIn('$rounds=%d$' % rounds, salt)
+                self.assertEqual(len(salt) - method.salt_chars,
+                                 11 + len(str(rounds)))
+                cr = crypt.crypt('mypassword', salt)
+                self.assertTrue(cr)
+                cr2 = crypt.crypt('mypassword', cr)
+                self.assertEqual(cr2, cr)
+
     @unittest.skipUnless(crypt.METHOD_BLOWFISH in crypt.methods,
                         'requires support of Blowfish')
-    def test_log_rounds(self):
-        self.assertEqual(len(crypt._saltchars), 64)
+    def test_blowfish_rounds(self):
         for log_rounds in range(4, 11):
-            salt = crypt.mksalt(crypt.METHOD_BLOWFISH, log_rounds=log_rounds)
+            salt = crypt.mksalt(crypt.METHOD_BLOWFISH, rounds=1 << log_rounds)
             self.assertIn('$%02d$' % log_rounds, salt)
             self.assertIn(len(salt) - crypt.METHOD_BLOWFISH.salt_chars, {6, 7})
             cr = crypt.crypt('mypassword', salt)
@@ -52,18 +66,21 @@ class CryptTestCase(unittest.TestCase):
             cr2 = crypt.crypt('mypassword', cr)
             self.assertEqual(cr2, cr)
 
-    @unittest.skipUnless(crypt.METHOD_BLOWFISH in crypt.methods,
-                        'requires support of Blowfish')
-    def test_invalid_log_rounds(self):
-        for log_rounds in (1, -1, 999):
-            salt = crypt.mksalt(crypt.METHOD_BLOWFISH, log_rounds=log_rounds)
-            cr = crypt.crypt('mypassword', salt)
-            if cr is not None:
-                # On failure the openwall implementation returns a magic
-                # string that is shorter than 13 characters and is guaranteed
-                # to differ from a salt.
-                self.assertNotEqual(cr, salt)
-                self.assertLess(len(cr), 13)
+    def test_invalid_rounds(self):
+        for method in (crypt.METHOD_SHA256, crypt.METHOD_SHA512,
+                       crypt.METHOD_BLOWFISH):
+            with self.assertRaises(TypeError):
+                crypt.mksalt(method, rounds='4096')
+            with self.assertRaises(TypeError):
+                crypt.mksalt(method, rounds=4096.0)
+            for rounds in (0, 1, -1, 1<<999):
+                with self.assertRaises(ValueError):
+                    crypt.mksalt(method, rounds=rounds)
+        with self.assertRaises(ValueError):
+            crypt.mksalt(crypt.METHOD_BLOWFISH, rounds=1000)
+        for method in (crypt.METHOD_CRYPT, crypt.METHOD_MD5):
+            with self.assertRaisesRegex(ValueError, 'support'):
+                crypt.mksalt(method, rounds=4096)
 
 
 if __name__ == "__main__":
diff --git a/Misc/NEWS.d/next/Library/2017-10-24-21-10-44.bpo-31702.SfwJDI.rst b/Misc/NEWS.d/next/Library/2017-10-24-21-10-44.bpo-31702.SfwJDI.rst
new file mode 100644 (file)
index 0000000..3505cbd
--- /dev/null
@@ -0,0 +1,2 @@
+crypt.mksalt() now allows to specify the number of rounds for SHA-256 and
+SHA-512 hashing.