Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
60 changes: 60 additions & 0 deletions src/_crypt_r.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
};

Expand Down
15 changes: 14 additions & 1 deletion src/crypt_r.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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'.
Expand Down
37 changes: 37 additions & 0 deletions tests/test_crypt_r.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading