diff --git a/README.rst b/README.rst index 95dc17d..2720c9d 100644 --- a/README.rst +++ b/README.rst @@ -21,8 +21,6 @@ not security/cryptography. It is easy to use ``crypt_r`` in an insecure way. Notably: All hashing methods except ``METHOD_CRYPT`` (the original Unix algorithm from the 1970s) are optional platform-specific extensions. -This library does not expose modern hashing methods like libxcrypt's yescrypt. -The last wrapper update is from 2017. No future development is planned. To use this module, you can either import ``crypt_r`` explicitly @@ -62,6 +60,12 @@ are available on all platforms): Another Modular Crypt Format method with 16 character salt and 43 character hash based on the SHA-256 hash function. +``METHOD_YESCRYPT`` + Another Modular Crypt Format method with 22 character salt and 43 + character hash based on the yescrypt key derivation function, + recommended by libxcrypt for new hashes. Available only when built + against libxcrypt, whose `crypt_gensalt(3)`_ generates the salt. + ``METHOD_BLOWFISH`` Another Modular Crypt Format method with 22 character salt and 31 character hash based on the Blowfish cipher. @@ -131,12 +135,15 @@ The ``crypt_r`` module defines the following functions: to ``crypt`` . *rounds* specifies the number of rounds for ``METHOD_SHA256``, - ``METHOD_SHA512`` and ``METHOD_BLOWFISH``. + ``METHOD_SHA512`` and ``METHOD_BLOWFISH``, and the cost for + ``METHOD_YESCRYPT``. 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`). + (2\ :sup:`12`). For ``METHOD_YESCRYPT`` it is libxcrypt's cost + parameter, an integer between ``1`` and ``11``; the default is chosen + by libxcrypt. New in Python 3.3. @@ -207,5 +214,6 @@ please refer to the `Python 3.12 Changelog`_. .. _PEP 594: https://peps.python.org/pep-0594/#crypt .. _crypt(3): https://manpages.debian.org/crypt(3) .. _crypt_r(3): https://manpages.debian.org/crypt_r(3) +.. _crypt_gensalt(3): https://manpages.debian.org/crypt_gensalt(3) .. _hmac.compare_digest(): https://docs.python.org/3/library/hmac.html#hmac.compare_digest .. _Python 3.12 Changelog: https://docs.python.org/3.12/whatsnew/changelog.html diff --git a/src/_crypt_r.c b/src/_crypt_r.c index 066ed53..0731c44 100644 --- a/src/_crypt_r.c +++ b/src/_crypt_r.c @@ -82,8 +82,68 @@ crypt_r_crypt(PyObject *module, PyObject *const *args, Py_ssize_t nargs) } +PyDoc_STRVAR(crypt_r_crypt_gensalt__doc__, +"crypt_gensalt($module, prefix, count, /)\n" +"--\n" +"\n" +"Return a setting string for the hashing method selected by *prefix*\n" +"(e.g. \"$y$\") with cost *count* (0 selects the method's default),\n" +"salted with random bytes from the operating system.\n" +"\n" +"Raises OSError with errno ENOSYS if the crypt library cannot generate\n" +"salts on its own."); + +static PyObject * +crypt_r_crypt_gensalt(PyObject *module, PyObject *const *args, Py_ssize_t nargs) +{ + if (nargs != 2) { + PyErr_Format(PyExc_TypeError, "crypt_gensalt expected 2 arguments, got %zd", nargs); + return NULL; + } + const char *prefix; + Py_ssize_t prefix_length; + unsigned long count; + + if (!PyUnicode_Check(args[0])) { + PyErr_Format( + PyExc_TypeError, "crypt_gensalt argument 1 (prefix) must be string, not %s", + Py_TYPE(args[0])->tp_name + ); + return NULL; + } + prefix = PyUnicode_AsUTF8AndSize(args[0], &prefix_length); + if (prefix == NULL) { + return NULL; + } + if (strlen(prefix) != (size_t)prefix_length) { + PyErr_SetString( + PyExc_ValueError, + "crypt_gensalt argument 1 (prefix) contains embedded null character" + ); + return NULL; + } + + count = PyLong_AsUnsignedLong(args[1]); + if (count == (unsigned long)-1 && PyErr_Occurred()) { + return NULL; + } + +#ifdef CRYPT_GENSALT_IMPLEMENTS_AUTO_ENTROPY + char output[CRYPT_GENSALT_OUTPUT_SIZE]; + if (crypt_gensalt_rn(prefix, count, NULL, 0, output, sizeof(output)) == NULL) { + return PyErr_SetFromErrno(PyExc_OSError); + } + return PyUnicode_FromString(output); +#else + errno = ENOSYS; + return PyErr_SetFromErrno(PyExc_OSError); +#endif +} + + static PyMethodDef crypt_r_methods[] = { {"crypt", (PyCFunction)crypt_r_crypt, METH_FASTCALL, crypt_r_crypt__doc__}, + {"crypt_gensalt", (PyCFunction)crypt_r_crypt_gensalt, METH_FASTCALL, crypt_r_crypt_gensalt__doc__}, {NULL, NULL} /* sentinel */ }; diff --git a/src/crypt_r.py b/src/crypt_r.py index afd6d82..34d41d9 100644 --- a/src/crypt_r.py +++ b/src/crypt_r.py @@ -45,6 +45,15 @@ def mksalt(method=None, *, rounds=None): else: # modular s = f'${method.ident}$' + if method.ident == 'y': # yescrypt + # The salt must be 16 bytes in yescrypt's own base64 encoding, and the + # cost is encoded into the parameters: let libxcrypt build both. + if rounds is None: + rounds = 0 # libxcrypt's default cost + elif not 1 <= rounds <= 11: + raise ValueError('rounds out of the range 1 to 11') + return _crypt_r.crypt_gensalt(s, rounds) + if method.ident and method.ident[0] == '2': # Blowfish variants if rounds is None: log_rounds = 12 @@ -88,9 +97,9 @@ def crypt(word, salt=None): def _add_method(name, *args, rounds=None): method = _Method(name, *args) globals()['METHOD_' + name] = method - salt = mksalt(method, rounds=rounds) result = None try: + salt = mksalt(method, rounds=rounds) result = crypt('', salt) except OSError as e: # Not all libc libraries support all encryption methods. @@ -105,6 +114,10 @@ def _add_method(name, *args, rounds=None): _add_method('SHA512', '6', 16, 106) _add_method('SHA256', '5', 16, 63) +# Listed after SHA-2, like Blowfish, so the default method stays the same; +# the lowest cost keeps the import-time probe as cheap as Blowfish's. +_add_method('YESCRYPT', 'y', 22, 73, rounds=1) + # 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'. diff --git a/tests/test_crypt_r.py b/tests/test_crypt_r.py index ef8769e..1e91f59 100644 --- a/tests/test_crypt_r.py +++ b/tests/test_crypt_r.py @@ -69,7 +69,44 @@ def test_blowfish_rounds(self): cr2 = self.crypt.crypt('mypassword', cr) self.assertEqual(cr2, cr) + @unittest.skipUnless( + crypt_r.METHOD_YESCRYPT in crypt_r.methods, 'requires support of yescrypt' + ) + def test_yescrypt(self): + # Adding yescrypt must not change the default method. + self.assertNotEqual(self.crypt.methods[0], self.crypt.METHOD_YESCRYPT) + # Arbitrary characters from _saltchars are not a valid yescrypt salt, + # so every generated one must be accepted, not just most of them. + salts = {self.crypt.mksalt(self.crypt.METHOD_YESCRYPT) for _ in range(20)} + self.assertEqual(len(salts), 20) + for salt in salts: + cr = self.crypt.crypt('mypassword', salt) + self.assertEqual(len(cr), self.crypt.METHOD_YESCRYPT.total_size) + self.assertTrue(cr.startswith(salt + '$')) + self.assertEqual(self.crypt.crypt('mypassword', cr), cr) + cr = self.crypt.crypt('mypassword', self.crypt.METHOD_YESCRYPT) + self.assertNotEqual(self.crypt.crypt('wrongpassword', cr), cr) + + @unittest.skipUnless( + crypt_r.METHOD_YESCRYPT in crypt_r.methods, 'requires support of yescrypt' + ) + def test_yescrypt_rounds(self): + params = set() + for rounds in range(1, 6): + salt = self.crypt.mksalt(self.crypt.METHOD_YESCRYPT, rounds=rounds) + self.assertTrue(salt.startswith('$y$')) + params.add(salt[:-self.crypt.METHOD_YESCRYPT.salt_chars]) + cr = self.crypt.crypt('mypassword', salt) + self.assertTrue(cr.startswith(salt + '$')) + self.assertEqual(self.crypt.crypt('mypassword', cr), cr) + self.assertEqual(len(params), 5) + def test_invalid_rounds(self): + with self.assertRaises(TypeError): + self.crypt.mksalt(self.crypt.METHOD_YESCRYPT, rounds='5') + for rounds in (0, 12, -1, 1<<999): + with self.assertRaises(ValueError): + self.crypt.mksalt(self.crypt.METHOD_YESCRYPT, rounds=rounds) for method in (self.crypt.METHOD_SHA256, self.crypt.METHOD_SHA512, self.crypt.METHOD_BLOWFISH): with self.assertRaises(TypeError):