From eab3ff72ebe79416cc032b8508ae13332955a157 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 24 Oct 2017 19:36:17 +0300 Subject: bpo-31664: Add support for the Blowfish method in crypt. (#3854) --- Doc/library/crypt.rst | 29 ++++++++---- Doc/whatsnew/3.7.rst | 6 +++ Lib/crypt.py | 46 ++++++++++++++----- Lib/test/test_crypt.py | 53 +++++++++++++++++----- .../2017-10-04-20-36-28.bpo-31664.4VDUzo.rst | 1 + 5 files changed, 104 insertions(+), 31 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2017-10-04-20-36-28.bpo-31664.4VDUzo.rst diff --git a/Doc/library/crypt.rst b/Doc/library/crypt.rst index dbd4274..9877b71 100644 --- a/Doc/library/crypt.rst +++ b/Doc/library/crypt.rst @@ -41,17 +41,24 @@ are available on all platforms): .. data:: METHOD_SHA512 A Modular Crypt Format method with 16 character salt and 86 character - hash. This is the strongest method. + hash based on the SHA-512 hash function. This is the strongest method. .. data:: METHOD_SHA256 Another Modular Crypt Format method with 16 character salt and 43 - character hash. + character hash based on the SHA-256 hash function. + +.. data:: METHOD_BLOWFISH + + Another Modular Crypt Format method with 22 character salt and 31 + character hash based on the Blowfish cipher. + + .. versionadded:: 3.7 .. data:: METHOD_MD5 Another Modular Crypt Format method with 8 character salt and 22 - character hash. + character hash based on the MD5 hash function. .. data:: METHOD_CRYPT @@ -109,19 +116,25 @@ The :mod:`crypt` module defines the following functions: Accept ``crypt.METHOD_*`` values in addition to strings for *salt*. -.. function:: mksalt(method=None) +.. function:: mksalt(method=None, *, log_rounds=12) Return a randomly generated salt of the specified method. If no *method* is given, the strongest method available as returned by :func:`methods` is used. - The return value is a string either of 2 characters in length for - ``crypt.METHOD_CRYPT``, or 19 characters starting with ``$digit$`` and - 16 random characters from the set ``[./a-zA-Z0-9]``, suitable for - passing as the *salt* argument to :func:`crypt`. + 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. .. versionadded:: 3.3 + .. versionchanged:: 3.7 + Added the *log_rounds* parameter. + + Examples -------- diff --git a/Doc/whatsnew/3.7.rst b/Doc/whatsnew/3.7.rst index 80f73b6..46121dc 100644 --- a/Doc/whatsnew/3.7.rst +++ b/Doc/whatsnew/3.7.rst @@ -229,6 +229,12 @@ contextlib :func:`contextlib.asynccontextmanager` has been added. (Contributed by Jelle Zijlstra in :issue:`29679`.) +crypt +----- + +Added support for the Blowfish method. +(Contributed by Serhiy Storchaka in :issue:`31664`.) + dis --- diff --git a/Lib/crypt.py b/Lib/crypt.py index fbc5f4c..4d73202 100644 --- a/Lib/crypt.py +++ b/Lib/crypt.py @@ -19,7 +19,7 @@ class _Method(_namedtuple('_Method', 'name ident salt_chars total_size')): return ''.format(self.name) -def mksalt(method=None): +def mksalt(method=None, *, log_rounds=12): """Generate a salt for the specified method. If not specified, the strongest available method will be used. @@ -27,7 +27,12 @@ def mksalt(method=None): """ if method is None: method = methods[0] - s = '${}$'.format(method.ident) if method.ident else '' + if not method.ident: + s = '' + elif method.ident[0] == '2': + s = f'${method.ident}${log_rounds:02d}$' + else: + s = f'${method.ident}$' s += ''.join(_sr.choice(_saltchars) for char in range(method.salt_chars)) return s @@ -48,14 +53,31 @@ def crypt(word, salt=None): # available salting/crypto methods -METHOD_CRYPT = _Method('CRYPT', None, 2, 13) -METHOD_MD5 = _Method('MD5', '1', 8, 34) -METHOD_SHA256 = _Method('SHA256', '5', 16, 63) -METHOD_SHA512 = _Method('SHA512', '6', 16, 106) - methods = [] -for _method in (METHOD_SHA512, METHOD_SHA256, METHOD_MD5, METHOD_CRYPT): - _result = crypt('', _method) - if _result and len(_result) == _method.total_size: - methods.append(_method) -del _result, _method + +def _add_method(name, *args): + method = _Method(name, *args) + globals()['METHOD_' + name] = method + salt = mksalt(method, log_rounds=4) + result = crypt('', salt) + if result and len(result) == method.total_size: + methods.append(method) + return True + return False + +_add_method('SHA512', '6', 16, 106) +_add_method('SHA256', '5', 16, 63) + +# Choose the strongest supported version of Blowfish hashing. +# Early versions have flaws. Version 'a' fixes flaws of +# the initial implementation, 'b' fixes flaws of 'a'. +# '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)): + break + +_add_method('MD5', '1', 8, 34) +_add_method('CRYPT', None, 2, 13) + +del _v, _add_method diff --git a/Lib/test/test_crypt.py b/Lib/test/test_crypt.py index e4f5897..8db1aef 100644 --- a/Lib/test/test_crypt.py +++ b/Lib/test/test_crypt.py @@ -1,3 +1,4 @@ +import sys from test import support import unittest @@ -6,28 +7,58 @@ crypt = support.import_module('crypt') class CryptTestCase(unittest.TestCase): def test_crypt(self): - c = crypt.crypt('mypassword', 'ab') - if support.verbose: - print('Test encryption: ', c) + cr = crypt.crypt('mypassword') + cr2 = crypt.crypt('mypassword', cr) + self.assertEqual(cr2, cr) + cr = crypt.crypt('mypassword', 'ab') + if cr is not None: + cr2 = crypt.crypt('mypassword', cr) + self.assertEqual(cr2, cr) def test_salt(self): self.assertEqual(len(crypt._saltchars), 64) for method in crypt.methods: salt = crypt.mksalt(method) - self.assertEqual(len(salt), - method.salt_chars + (3 if method.ident else 0)) + self.assertIn(len(salt) - method.salt_chars, {0, 1, 3, 4, 6, 7}) + if method.ident: + self.assertIn(method.ident, salt[:len(salt)-method.salt_chars]) def test_saltedcrypt(self): for method in crypt.methods: - pw = crypt.crypt('assword', method) - self.assertEqual(len(pw), method.total_size) - pw = crypt.crypt('assword', crypt.mksalt(method)) - self.assertEqual(len(pw), method.total_size) + cr = crypt.crypt('assword', method) + self.assertEqual(len(cr), method.total_size) + cr2 = crypt.crypt('assword', cr) + self.assertEqual(cr2, cr) + cr = crypt.crypt('assword', crypt.mksalt(method)) + self.assertEqual(len(cr), method.total_size) def test_methods(self): - # Guarantee that METHOD_CRYPT is the last method in crypt.methods. self.assertTrue(len(crypt.methods) >= 1) - self.assertEqual(crypt.METHOD_CRYPT, crypt.methods[-1]) + if sys.platform.startswith('openbsd'): + self.assertEqual(crypt.methods, [crypt.METHOD_BLOWFISH]) + else: + self.assertEqual(crypt.methods[-1], crypt.METHOD_CRYPT) + + @unittest.skipUnless(crypt.METHOD_BLOWFISH in crypt.methods, + 'requires support of Blowfish') + def test_log_rounds(self): + self.assertEqual(len(crypt._saltchars), 64) + for log_rounds in range(4, 11): + salt = crypt.mksalt(crypt.METHOD_BLOWFISH, log_rounds=log_rounds) + self.assertIn('$%02d$' % log_rounds, salt) + self.assertIn(len(salt) - crypt.METHOD_BLOWFISH.salt_chars, {6, 7}) + 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_invalid_log_rounds(self): + for log_rounds in (1, -1, 999): + salt = crypt.mksalt(crypt.METHOD_BLOWFISH, log_rounds=log_rounds) + self.assertIsNone(crypt.crypt('mypassword', salt)) + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Library/2017-10-04-20-36-28.bpo-31664.4VDUzo.rst b/Misc/NEWS.d/next/Library/2017-10-04-20-36-28.bpo-31664.4VDUzo.rst new file mode 100644 index 0000000..bd84749 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2017-10-04-20-36-28.bpo-31664.4VDUzo.rst @@ -0,0 +1 @@ +Added support for the Blowfish hashing in the crypt module. -- cgit v0.12