From 2132ab8c3d2facc97d96c2668e1bf43c3ac666ff Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sun, 6 Dec 2015 20:21:05 +0100 Subject: [PATCH] Initial work --- .coveragerc | 12 +++ .gitignore | 13 +++ CHANGELOG.rst | 11 ++ CODE_OF_CONDUCT.rst | 25 +++++ CONTRIBUTING.rst | 39 ++++++++ LICENSE | 21 ++++ MANIFEST.in | 6 ++ README.rst | 98 ++++++++++++++++++ setup.cfg | 17 ++++ setup.py | 92 +++++++++++++++++ src/argon2/__init__.py | 43 ++++++++ src/argon2/__main__.py | 80 +++++++++++++++ src/argon2/_api.py | 118 ++++++++++++++++++++++ src/argon2/_ffi_build.py | 86 ++++++++++++++++ src/argon2/_util.py | 103 +++++++++++++++++++ src/argon2/exceptions.py | 30 ++++++ tests/__init__.py | 0 tests/test_api.py | 211 +++++++++++++++++++++++++++++++++++++++ tests/test_util.py | 68 +++++++++++++ tox.ini | 41 ++++++++ 20 files changed, 1114 insertions(+) create mode 100644 .coveragerc create mode 100644 .gitignore create mode 100644 CHANGELOG.rst create mode 100644 CODE_OF_CONDUCT.rst create mode 100644 CONTRIBUTING.rst create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 src/argon2/__init__.py create mode 100644 src/argon2/__main__.py create mode 100644 src/argon2/_api.py create mode 100644 src/argon2/_ffi_build.py create mode 100644 src/argon2/_util.py create mode 100644 src/argon2/exceptions.py create mode 100644 tests/__init__.py create mode 100644 tests/test_api.py create mode 100644 tests/test_util.py create mode 100644 tox.ini diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..354c7da --- /dev/null +++ b/.coveragerc @@ -0,0 +1,12 @@ +[run] +branch = True +source = argon2 + +[paths] +source = + src/argon2 + .tox/*/lib/python*/site-packages/argon2 + .tox/pypy/site-packages/argon2 + +[report] +show_missing = True diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbadf60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +dist +.tox +.eggs +.coverage +.coverage.* +.cache +src/argon2/_ffi.py +__pycache__ +*.pyc +*.egg-info +*.dylib +*.so +.hypothesis diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..b551400 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,11 @@ +Changelog +========= + +Versions are year-based with a strict backward compatibility policy. +The third digit is only for regressions. + + +15.0.0 (UNRELEASED) +------------------- + +Initial work. diff --git a/CODE_OF_CONDUCT.rst b/CODE_OF_CONDUCT.rst new file mode 100644 index 0000000..f902e7c --- /dev/null +++ b/CODE_OF_CONDUCT.rst @@ -0,0 +1,25 @@ +Contributor Code of Conduct +=========================== + +As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. + +We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, such as physical or electronic addresses, without explicit permission +* Other unethical or unprofessional conduct. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. +By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. +Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. + +This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. + +This Code of Conduct is adapted from the `Contributor Covenant `_, version 1.2.0, available at http://contributor-covenant.org/version/1/2/0/. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..454acd3 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,39 @@ +How To Contribute +================= + +Every open source project lives from the generous help by contributors that sacrifice their time and ``argon2_cffi`` is no different. + +Here are a few guidelines to get you started: + +- To run the test suite, all you need is a recent tox_. + It will ensure the test suite runs with all dependencies against all Python versions just as it will on `Travis CI`_. + If you lack some Python version, you can can always limit the environments like ``tox -e py27,py35`` (in that case you may want to look into pyenv_ that makes it very easy to install many different Python versions in parallel). +- Make sure your changes pass our CI. + You won't get any feedback until it's green unless you ask for it. +- If your change is noteworthy, add an entry to the changelog_. +- No contribution is too small; please submit as many fixes for typos and grammar bloopers as you can! +- Don’t break `backward compatibility`_. +- *Always* add tests and docs for your code. + This is a hard rule; patches with missing tests or documentation won’t be merged. +- Write `good test docstrings`_. +- Obey `PEP 8`_ and `PEP 257`_. +- If you address review feedback, make sure to bump the pull request. + Maintainers don’t receive notifications if you push new commits. + +Please note that this project is released with a Contributor `Code of Conduct`_. +By participating in this project you agree to abide by its terms. +Please report any harm to `Hynek Schlawack `_ in any way you find appropriate. + +Thank you for considering to contribute to ``argon2_cffi``! + + +.. _me: https://hynek.me/about/ +.. _`PEP 8`: https://www.python.org/dev/peps/pep-0008/ +.. _`PEP 257`: https://www.python.org/dev/peps/pep-0257/ +.. _`good test docstrings`: https://jml.io/pages/test-docstrings.html +.. _`Code of Conduct`: https://github.com/hynek/argon2_cffi/blob/master/CODE_OF_CONDUCT.rst +.. _changelog: https://github.com/hynek/argon2_cffi/blob/master/CHANGELOG.rst +.. _`backward compatibility`: https://argon2_cffi.readthedocs.org/en/latest/backward-compatibility.html +.. _`tox`: https://testrun.org/tox/ +.. _`Travis CI`: https://travis-ci.org/ +.. _pyenv: https://github.com/yyuu/pyenv diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ae3df9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Hynek Schlawack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..2ad7202 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include *.rst *.txt +include .coveragerc +include LICENSE +include tox.ini +recursive-include tests *.py +exclude src/argon2/_ffi.py diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..fa91b33 --- /dev/null +++ b/README.rst @@ -0,0 +1,98 @@ +===================================== +CFFI-based Argon2 Bindings for Python +===================================== + +`Argon2 `_ won the `Password Hashing Competition `_ in 2015. +``argon2_cffi`` is the simplest way to use it in Python and PyPy: + +.. code-block:: pycon + + >>> import argon2 + >>> encoded_hash = argon2.hash_password(b"secret", b"somesalt") + >>> encoded_hash + b'$argon2i$m=4096,t=3,p=2$c29tZXNhbHQ$FNqxwHC2l1liWu3JTgGn6w' + >>> argon2.verify_password(encoded_hash, b"secret") + True + >>> argon2.verify_password(encoded_hash, b"wrong") + Traceback (most recent call last): + ... + argon2.exceptions.VerificationError: Decoding failed + +You can omit the ``salt`` argument for a secure random salt of length ``argon2.DEFAULT_RANDOM_SALT_LENGTH``: + +.. code-block:: pycon + + >>> argon2.hash_password(b"secret") # doctest: +SKIP + b'$argon2i$m=4096,t=3,p=2$GIESi4asMZaP051OPlH/zw$s5bQHIupLB1Fep/U5NXIVQ' + + +Hands-on +======== + +``argon2_cffi`` comes with hopefully reasonable defaults for Argon2 parameters. +But of course, you can set them yourself if you wish: + +.. code-block:: pycon + + >>> argon2.hash_password( + ... b"secret", b"somesalt", + ... time_cost=1, # number of iterations + ... memory_cost=8, # used memory in KiB + ... parallelism=8, # number of threads used + ... hash_len=64, # length of resulting raw hash + ... type=argon2.Type.D, # choose Argon2i or Argon2d + ... ) + b'$argon2d$m=8,t=1,p=8$c29tZXNhbHQ$RGhaBn2bZoWmKwBrWsLZT4Y950n1efoRde77Af3OKWUomJoFz7nEYVQbM6bAk/rqAi0hDP0y6XO5qJ0y8cqwUA' + +The raw hash can also be computed. +The function takes the same parameters as ``hash_password()``: + +.. code-block:: pycon + + >>> argon2.hash_password_raw(b"secret", b"somesalt") + b'\x14\xda\xb1\xc0p\xb6\x97YbZ\xed\xc9N\x01\xa7\xeb' + + +Choosing Parameters +------------------- + +Finding the right parameters for a password hashing algorithm is a daunting task. +The authors of Argon2 specified a method in their `paper `_ but it should be noted that they also mention that no value for ``time_cost`` or ``memory_cost`` is actually insecure (cf. section 6.4). + + +#. Choose whether you want Argon2i or Argon2d (``type``). + If you don't know what that means, choose Argon2i (``Type.I``). +#. Figure out how many threads can be used on each call to Argon2 (``parallelism``). + They recommend twice as many as the number of cores dedicated to hashing passwords. +#. Figure out how much memory each call can afford (``memory_cost``). +#. Choose a salt length. + 16 Bytes are fine. +#. Choose a hash length (``hash_len``). + 16 Bytes are fine. +#. Figure out how long each call can take. + One `recommendation `_ for concurent user logins is to keep it under 0.5ms. +#. Measure the time for hashing using your chosen parameters. + Find a ``time_cost`` that is within your accounted time. + If ``time_cost=1`` takes too long, lower ``memory_cost``. + + +CLI +^^^ + +To aid you with finding the parameters, ``argon2_cffi`` can be run using ``python -m argon2`` which will benchmark Argon2 in the current environment.. +You can use command line arguments to set hashing parameters: + +.. code-block:: text + + $ python -m argon2 -t 1 -m 800 + Running Argon2i with: + hash_len: 16 + memory_cost: 800 + parallelism: 2 + time_cost: 1 + + Measuring... + + 0.515ms per password + +This should make it much easier to determine the right parameters for your use case and your environment. diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..25aefb2 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,17 @@ +# Packaging + +[wheel] +# we're pure-python +universal = 1 + + +# Testing + +[pytest] +minversion = 2.8 +strict = true +addopts = -ra +testpaths = tests + +[flake8] +exclude = src/argon2/_ffi.py diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..0ecd150 --- /dev/null +++ b/setup.py @@ -0,0 +1,92 @@ +from setuptools import setup, find_packages + +import sys +import codecs +import os +import re + + +HERE = os.path.abspath(os.path.dirname(__file__)) + +############################################################################### + +NAME = "argon2_cffi" +PACKAGES = find_packages(where="src") +CFFI_MODULES = [os.path.join(HERE, "src", "argon2", "_ffi_build.py:ffi")] +META_PATH = ("src", "argon2", "__init__.py") +KEYWORDS = ["password", "hash", "hashing", "security"] +CLASSIFIERS = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +SETUP_REQUIRES = ["cffi"] +INSTALL_REQUIRES = ["six", "cffi>=1.0.0"] +EXTRAS_REQUIRES = { + ':python_version<"3.4"': ["enum34"], # for wheels +} +if sys.version_info[0:2] < (3, 4): # for sdist + INSTALL_REQUIRES += ["enum34"] + +############################################################################### + + +def read(*parts): + """ + Build an absolute path from *parts* and and return the contents of the + resulting file. Assume UTF-8 encoding. + """ + with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f: + return f.read() + + +META_FILE = read(*META_PATH) + + +def find_meta(meta): + """ + Extract __*meta*__ from META_FILE. + """ + meta_match = re.search( + r"^__{meta}__ = ['\"]([^'\"]*)['\"]".format(meta=meta), + META_FILE, re.M + ) + if meta_match: + return meta_match.group(1) + raise RuntimeError("Unable to find __{meta}__ string.".format(meta=meta)) + + +if __name__ == "__main__": + setup( + name=NAME, + description=find_meta("description"), + license=find_meta("license"), + url=find_meta("uri"), + version=find_meta("version"), + author=find_meta("author"), + author_email=find_meta("email"), + maintainer=find_meta("author"), + maintainer_email=find_meta("email"), + long_description=read("README.rst") + "\n\n" + read("CHANGELOG.rst"), + keywords=KEYWORDS, + packages=PACKAGES, + package_dir={"": "src"}, + cffi_modules=CFFI_MODULES, + classifiers=CLASSIFIERS, + setup_requires=SETUP_REQUIRES, + install_requires=INSTALL_REQUIRES, + extras_requires=EXTRAS_REQUIRES, + zip_safe=False, + ) diff --git a/src/argon2/__init__.py b/src/argon2/__init__.py new file mode 100644 index 0000000..681eb61 --- /dev/null +++ b/src/argon2/__init__.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +from . import exceptions +from ._util import Type +from ._api import ( + DEFAULT_HASH_LENGTH, + DEFAULT_MEMORY_COST, + DEFAULT_PARALLELISM, + DEFAULT_RANDOM_SALT_LENGTH, + DEFAULT_TIME_COST, + hash_password, + hash_password_raw, + verify_password, +) + + +__version__ = "15.0.0.dev0" + +__title__ = "argon2_cffi" +__description__ = "argon2 password hashing algorithm." +__uri__ = "" + +__author__ = "Hynek Schlawack" +__email__ = "hs@ox.cx" + +__license__ = "MIT" +__copyright__ = "Copyright (c) 2015 {author}".format(author=__author__) + + +__all__ = [ + "DEFAULT_HASH_LENGTH", + "DEFAULT_MEMORY_COST", + "DEFAULT_PARALLELISM", + "DEFAULT_RANDOM_SALT_LENGTH", + "DEFAULT_TIME_COST", + "Type", + "exceptions", + "hash_password", + "hash_password_raw", + "verify_password", +] diff --git a/src/argon2/__main__.py b/src/argon2/__main__.py new file mode 100644 index 0000000..62a20ee --- /dev/null +++ b/src/argon2/__main__.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +import argparse +import os +import sys +import timeit + +import six + +from . import ( + hash_password, + DEFAULT_RANDOM_SALT_LENGTH, + DEFAULT_TIME_COST, + DEFAULT_MEMORY_COST, + DEFAULT_PARALLELISM, + DEFAULT_HASH_LENGTH, + Type, +) + + +def main(argv): + parser = argparse.ArgumentParser(description="Benchmark Argon2.") + parser.add_argument("-n", type=int, default=100, + help="Number of iterations to measure.") + parser.add_argument("-d", action="store_const", + const=Type.D, default=Type.I, + help="Use Argon2d instead of the default Argon2i.") + parser.add_argument("-t", type=int, help="`time_cost`", + default=DEFAULT_TIME_COST) + parser.add_argument("-m", type=int, help="`memory_cost`", + default=DEFAULT_MEMORY_COST) + parser.add_argument("-p", type=int, help="`parallellism`", + default=DEFAULT_PARALLELISM) + parser.add_argument("-l", type=int, help="`hash_length`", + default=DEFAULT_HASH_LENGTH) + + args = parser.parse_args(argv[1:]) + + password = b"secret" + salt = os.urandom(DEFAULT_RANDOM_SALT_LENGTH) + + hash = hash_password( + password, salt, + time_cost=args.t, + memory_cost=args.m, + parallelism=args.p, + type=args.d, + hash_len=args.l, + ) + + params = { + "time_cost": args.t, + "memory_cost": args.m, + "parallelism": args.p, + "hash_len": args.l, + } + + print("Running Argon2{0} {1} times with:".format( + Type(args.d).name.lower(), + args.n, + )) + + for k, v in sorted(six.iteritems(params)): + print("{0}: {1}".format(k, v)) + + print("\nMeasuring...") + duration = timeit.timeit( + "verify_password({hash!r}, {password!r})".format( + hash=hash, password=password + ), + setup="from argon2 import verify_password; gc.enable()", + number=args.n, + ) + print("\n{0:.3}ms per password".format(duration / args.n * 1000)) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/src/argon2/_api.py b/src/argon2/_api.py new file mode 100644 index 0000000..d461837 --- /dev/null +++ b/src/argon2/_api.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +import os + +from ._util import ( + check_types, error_to_str, guess_type, ffi, lib, get_encoded_len, Type, + NoneType, +) +from .exceptions import VerificationError, HashingError + + +__all__ = [ + "DEFAULT_RANDOM_SALT_LENGTH", + "hash_password", + "hash_password_raw", + "verify_password", +] + +DEFAULT_RANDOM_SALT_LENGTH = 16 +DEFAULT_HASH_LENGTH = 16 +DEFAULT_TIME_COST = 3 +DEFAULT_MEMORY_COST = 2**12 +DEFAULT_PARALLELISM = 2 + + +def hash_password(password, salt=None, + time_cost=DEFAULT_TIME_COST, + memory_cost=DEFAULT_MEMORY_COST, + parallelism=DEFAULT_PARALLELISM, + hash_len=DEFAULT_HASH_LENGTH, + type=Type.I): + return _hash(password, salt, time_cost, memory_cost, parallelism, hash_len, + type, True) + + +def hash_password_raw(password, salt=None, + time_cost=DEFAULT_TIME_COST, + memory_cost=DEFAULT_MEMORY_COST, + parallelism=DEFAULT_PARALLELISM, + hash_len=DEFAULT_HASH_LENGTH, + type=Type.I): + return _hash(password, salt, time_cost, memory_cost, parallelism, hash_len, + type, False) + + +def _hash(password, salt, time_cost, memory_cost, parallelism, hash_len, type, + encoded): + e = check_types( + password=(password, bytes), + salt=(salt, (bytes, NoneType)), + time_cost=(time_cost, int), + memory_cost=(memory_cost, int), + parallelism=(parallelism, int), + hash_len=(hash_len, int), + type=(type, Type), + encoded=(encoded, bool), + ) + if e: + raise TypeError(e) + if salt is None: + salt = os.urandom(DEFAULT_RANDOM_SALT_LENGTH) + + raw_buf = encoded_buf = ffi.NULL + raw_len = encoded_len = 0 + if encoded: + encoded_len = get_encoded_len(hash_len, len(salt)) + encoded_buf = ffi.new("char[]", encoded_len) + else: + raw_len = hash_len + raw_buf = ffi.new("char[]", raw_len) + + rv = lib.argon2_hash( + time_cost, memory_cost, parallelism, + ffi.new("char[]", password), len(password), + ffi.new("char[]", salt), len(salt), + raw_buf, hash_len, + encoded_buf, encoded_len, + type.value, + ) + if rv != lib.ARGON2_OK: + raise HashingError(error_to_str(rv)) + + return ( + ffi.string(encoded_buf) if encoded_len != 0 + else bytes(ffi.buffer(raw_buf)) + ) + + +def verify_password(hash, password, type=None): + """ + Verify whether *password* is correct for *hash* of *type*. + + :return: ``True`` on success, throw exception otherwise. + :rtype: bool + """ + e = check_types( + password=(password, bytes), + hash=(hash, bytes), + type=(type, (Type, NoneType)), + ) + if e: + raise TypeError(e) + + if type is None: + type = guess_type(hash) + + rv = lib.argon2_verify( + ffi.new("char[]", hash), + ffi.new("char[]", password), + len(password), + type.value, + ) + if rv == lib.ARGON2_OK: + return True + else: + raise VerificationError(error_to_str(rv)) diff --git a/src/argon2/_ffi_build.py b/src/argon2/_ffi_build.py new file mode 100644 index 0000000..2cbb776 --- /dev/null +++ b/src/argon2/_ffi_build.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +from cffi import FFI + + +ffi = FFI() +ffi.set_source( + "argon2._ffi", None, + libraries=["argon2"], +) + +ffi.cdef("""\ +typedef enum Argon2_type { Argon2_d = 0, Argon2_i = 1 } argon2_type; + +int argon2_hash(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, const size_t saltlen, + void *hash, const size_t hashlen, char *encoded, + const size_t encodedlen, argon2_type type); + +int argon2_verify(const char *encoded, const void *pwd, const size_t pwdlen, + argon2_type type); + +const char *error_message(int error_code); + +/* Error codes */ +typedef enum Argon2_ErrorCodes { + ARGON2_OK = 0, + + ARGON2_OUTPUT_PTR_NULL = 1, + + ARGON2_OUTPUT_TOO_SHORT = 2, + ARGON2_OUTPUT_TOO_LONG = 3, + + ARGON2_PWD_TOO_SHORT = 4, + ARGON2_PWD_TOO_LONG = 5, + + ARGON2_SALT_TOO_SHORT = 6, + ARGON2_SALT_TOO_LONG = 7, + + ARGON2_AD_TOO_SHORT = 8, + ARGON2_AD_TOO_LONG = 9, + + ARGON2_SECRET_TOO_SHORT = 10, + ARGON2_SECRET_TOO_LONG = 11, + + ARGON2_TIME_TOO_SMALL = 12, + ARGON2_TIME_TOO_LARGE = 13, + + ARGON2_MEMORY_TOO_LITTLE = 14, + ARGON2_MEMORY_TOO_MUCH = 15, + + ARGON2_LANES_TOO_FEW = 16, + ARGON2_LANES_TOO_MANY = 17, + + ARGON2_PWD_PTR_MISMATCH = 18, /* NULL ptr with non-zero length */ + ARGON2_SALT_PTR_MISMATCH = 19, /* NULL ptr with non-zero length */ + ARGON2_SECRET_PTR_MISMATCH = 20, /* NULL ptr with non-zero length */ + ARGON2_AD_PTR_MISMATCH = 21, /* NULL ptr with non-zero length */ + + ARGON2_MEMORY_ALLOCATION_ERROR = 22, + + ARGON2_FREE_MEMORY_CBK_NULL = 23, + ARGON2_ALLOCATE_MEMORY_CBK_NULL = 24, + + ARGON2_INCORRECT_PARAMETER = 25, + ARGON2_INCORRECT_TYPE = 26, + + ARGON2_OUT_PTR_MISMATCH = 27, + + ARGON2_THREADS_TOO_FEW = 28, + ARGON2_THREADS_TOO_MANY = 29, + + ARGON2_MISSING_ARGS = 30, + + ARGON2_ENCODING_FAIL = 31, + + ARGON2_DECODING_FAIL = 32, + + ARGON2_ERROR_CODES_LENGTH /* Do NOT remove; Do NOT add error codes after + this + error code */ +} argon2_error_codes; +""") diff --git a/src/argon2/_util.py b/src/argon2/_util.py new file mode 100644 index 0000000..51b0718 --- /dev/null +++ b/src/argon2/_util.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +import ctypes.util + +from enum import Enum +from six import iteritems, PY3 + + +from ._ffi import ffi +from .exceptions import InvalidHash + + +lib = ffi.dlopen(ctypes.util.find_library("argon2")) + + +class Type(Enum): + D = lib.Argon2_d + I = lib.Argon2_i + + +NoneType = type(None) + + +def check_types(**kw): + """ + Check each ``name: (value, types)`` in *kw*. + + Returns a human-readable string of all violations or `None``. + """ + errors = [] + for name, (value, types) in iteritems(kw): + if not isinstance(value, types): + if isinstance(types, tuple): + types = ", or ".join(t.__name__ for t in types) + else: + types = types.__name__ + errors.append("'{name}' must be a {type} (got {actual})".format( + name=name, + type=types, + actual=type(value).__name__, + )) + + if errors != []: + return ", ".join(errors) + "." + + +def error_to_str(error): + """ + Convert an Argon2 error code into a native string. + """ + msg = ffi.string(lib.error_message(error)) + if PY3: + msg = msg.decode("ascii") + return msg + + +def encoded_str_len(l): + """ + Compute how long a byte string of length *l* becomes if encoded to hex. + """ + return (l << 2) / 3 + 2 + + +def get_encoded_len(hash_len, salt_len): + """ + Compute the size of the required buffer for an encoded hash with *hash_len* + and *salt_len*. + """ + # From https://github.com/P-H-C/phc-winner-argon2/blob/master/src/run.c: + # + # Sample encode: $argon2i$m=65536,t=2,p=4$c29tZXNhbHQAAAAAAAAAAA$QWLzI4TY9H + # kL2ZTLc8g6SinwdhZewYrzz9zxCo0bkGY + # Maximumum lengths are defined as: + # strlen $argon2i$ = 9 + # m=65536 with strlen (uint32_t)-1 = 10, so this total is 12 + # ,t=2,p=4 If we consider each number to potentially reach four digits + # in future, this = 14 + # $c29tZXNhbHQAAAAAAAAAAA Formula for this is + # (SALT_LEN * 4 + 3) / 3 + 1 = 23 + # $QWLzI4TY9HkL2ZTLc8g6SinwdhZewYrzz9zxCo0bkGY per above formula, = 44 + # + NULL byte + # 9 + 12 + 14 + 23 + 44 + 1 = 103 + # Rounded to 4 byte boundary: 104 + + l = 36 + int(encoded_str_len(hash_len) + encoded_str_len(salt_len)) + while l % 4: # round up to 4 byte boundary. + l += 1 + return l + + +def guess_type(s): + """ + Guesses what type of encoded Argon2 hash *s* is or raises InvalidHash. + """ + prefix = s[:8] + if prefix == b"$argon2i": + return Type.I + elif prefix == b"$argon2d": + return Type.D + else: + raise InvalidHash(s) diff --git a/src/argon2/exceptions.py b/src/argon2/exceptions.py new file mode 100644 index 0000000..82761e1 --- /dev/null +++ b/src/argon2/exceptions.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + + +class Argon2Error(Exception): + """ + Superclass of all argon2 exceptions. + + Never thrown directly. + """ + + +class VerificationError(Argon2Error): + """ + Raised if verification failed. + """ + + +class InvalidHash(VerificationError): + """ + Raised if :func:`argon2.verify_password` should guess the type of a hash + but the hash is invalid. + """ + + +class HashingError(Argon2Error): + """ + Raised if hasing failed. + """ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..fd23478 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +import binascii + +import pytest + +from hypothesis import given +from hypothesis import strategies as st + +from argon2 import ( + hash_password, hash_password_raw, verify_password, Type, + DEFAULT_RANDOM_SALT_LENGTH, +) +from argon2.exceptions import VerificationError, HashingError +from argon2._util import encoded_str_len + +# Example data obtained using the official Argon2 CLI client: +# +# $ echo -n "password" | ./argon2 somesalt -t 2 -m 16 -p 4 +# Type: Argon2i +# Iterations: 2 +# Memory: 65536 KiB +# Parallelism: 4 +# Hash: 4162f32384d8f4790bd994cb73c83a4a29f076165ec18af3cfdcf10a8d1b9066 +# Encoded: $argon2i$m=65536,t=2,p=4$c29tZXNhbHQAAAAAAAAAAA$QWLzI4TY9HkL2ZTLc +# 8g6SinwdhZewYrzz9zxCo0bkGY +# 0.176 seconds +# Verification ok +# +# $ echo -n "password" | ./argon2 somesalt -t 2 -m 16 -p 4 -d +# Type: Argon2d +# Iterations: 2 +# Memory: 65536 KiB +# Parallelism: 4 +# Hash: 9ca3b9fc007d09daf489dcf854e9a785ff5a32c62ec50acf26477977add23225 +# Encoded: $argon2d$m=65536,t=2,p=4$c29tZXNhbHQAAAAAAAAAAA$nKO5/AB9Cdr0idz4V +# Omnhf9aMsYuxQrPJkd5d63SMiU +# 0.189 seconds +# Verification ok + +TEST_HASH_I = ( + b"$argon2i$m=65536,t=2,p=4" + b"$c29tZXNhbHQAAAAAAAAAAA" + b"$QWLzI4TY9HkL2ZTLc8g6SinwdhZewYrzz9zxCo0bkGY" +) +TEST_HASH_D = ( + b"$argon2d$m=65536,t=2,p=4" + b"$c29tZXNhbHQAAAAAAAAAAA$" + b"nKO5/AB9Cdr0idz4VOmnhf9aMsYuxQrPJkd5d63SMiU" +) +TEST_RAW_I = binascii.unhexlify( + b"4162f32384d8f4790bd994cb73c83a4a29f076165ec18af3cfdcf10a8d1b9066" +) +TEST_RAW_D = binascii.unhexlify( # N.B. includes NUL byte! + b"9ca3b9fc007d09daf489dcf854e9a785ff5a32c62ec50acf26477977add23225" +) +TEST_HASH_FAST = ( + b"$argon2i$m=8,t=1,p=1$c29tZXNhbHQAAAAAAAAAAA$owd7NH5aC7mrx3sIc0zMF+R8RkPH" + b"S23ZuFM0IO3uck8" +) # same password/salt, but much cheaper. + +TEST_PASSWORD = b"password" +TEST_SALT_LEN = 16 +TEST_SALT = b"somesalt" +TEST_SALT += b"\x00" * (TEST_SALT_LEN - len(TEST_SALT)) +TEST_TIME = 2 +TEST_MEMORY = 65536 +TEST_PARALLELISM = 4 +TEST_HASH_LEN = 32 + +i_and_d_encoded = pytest.mark.parametrize("type,hash", [ + (Type.I, TEST_HASH_I,), + (Type.D, TEST_HASH_D,), +]) +i_and_d_raw = pytest.mark.parametrize("type,hash", [ + (Type.I, TEST_RAW_I,), + (Type.D, TEST_RAW_D,), +]) + + +class TestHash(object): + @i_and_d_encoded + def test_hash_password(self, type, hash): + """ + Creates the same encoded hash as the Argon2 CLI client. + """ + rv = hash_password( + TEST_PASSWORD, + TEST_SALT, + TEST_TIME, + TEST_MEMORY, + TEST_PARALLELISM, + TEST_HASH_LEN, + type, + ) + + assert hash == rv + assert isinstance(rv, bytes) + + @i_and_d_raw + def test_hash_password_raw(self, type, hash): + """ + Creates the same raw hash as the Argon2 CLI client. + """ + rv = hash_password_raw( + TEST_PASSWORD, + TEST_SALT, + TEST_TIME, + TEST_MEMORY, + TEST_PARALLELISM, + TEST_HASH_LEN, + type, + ) + + assert hash == rv + assert isinstance(rv, bytes) + + def test_hash_nul_bytes(self): + """ + Hashing passwords with NUL bytes works as expected. + """ + rv = hash_password_raw(b"abc\x00", TEST_SALT) + + assert rv != hash_password_raw(b"abc", TEST_SALT) + + def test_random_salt(self): + """ + Omitting a salt, creates a random one. + """ + rv = hash_password(b"secret") + salt = rv.split(b",")[-1].split(b"$")[1] + assert ( + # -1 for not NUL byte + int(encoded_str_len(DEFAULT_RANDOM_SALT_LENGTH)) - 1 == len(salt) + ) + + def test_hash_wrong_arg_type(self): + """ + Passing an argument of wrong type raises TypeError. + """ + with pytest.raises(TypeError): + hash_password(u"oh no, unicode!") + + def test_illegal_argon2_parameter(self): + """ + Raises HashingError if hashing fails. + """ + with pytest.raises(HashingError): + hash_password(TEST_PASSWORD, memory_cost=1) + + @given(st.binary(max_size=128)) + def test_hash_fast(self, password): + """ + Hash various passwords as cheaply as possible. + """ + hash_password( + password, + salt=b"12345678", + time_cost=1, + memory_cost=8, + parallelism=1, + hash_len=8, + ) + + +class TestVerify(object): + @i_and_d_encoded + def test_auto_success(self, type, hash): + """ + Given a valid hash and password, we figure out the type ourself and + succeed. + """ + assert True is verify_password(hash, TEST_PASSWORD) + + @i_and_d_encoded + def test_explicit_success(self, type, hash): + """ + Given a valid hash and password and correct type, we succeed. + """ + assert True is verify_password(hash, TEST_PASSWORD, type) + + def test_explicit_fail(self): + """ + Given a valid hash and password and wrong type, we fail. + """ + with pytest.raises(VerificationError): + verify_password(TEST_HASH_I, TEST_PASSWORD, Type.D) + + def test_wrong_arg_type(self): + """ + Passing an argument of wrong type raises TypeError. + """ + with pytest.raises(TypeError): + verify_password(TEST_HASH_I, TEST_PASSWORD.decode("ascii")) + + def test_auto_fast(self): + """ + Just a test ensure that test_auto_fail works on correct data. + """ + assert verify_password(TEST_HASH_FAST, b"password") + + @given(st.binary(max_size=128)) + def test_auto_fail(self, wrong_password): + """ + Given a valid but wrong hash and password, we figure out the type + ourself and fail. + """ + with pytest.raises(VerificationError): + verify_password(TEST_HASH_FAST, wrong_password) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..16f05b6 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +from six import PY3 + +import pytest + +from hypothesis import given +from hypothesis import strategies as st + +from argon2.exceptions import InvalidHash +from argon2._util import get_encoded_len, check_types, NoneType, guess_type + +from .test_api import i_and_d_encoded + + +def test_get_encoded_len(): + """ + Verify we get the same result as the official example. + """ + assert 104 == get_encoded_len(32, 16) + + +class TestCheckTypes(object): + def test_success(self): + """ + Returns None if all types are okay. + """ + assert None is check_types( + bytes=(b"bytes", bytes), + tuple=((1, 2), tuple), + str_or_None=(None, (str, NoneType)), + ) + + def test_fail(self): + """ + Returns summary of failures. + """ + rv = check_types( + bytes=(u"not bytes", bytes), + str_or_None=(42, (str, NoneType)) + ) + + assert "." == rv[-1] # proper grammar FTW + assert "'str_or_None' must be a str, or NoneType (got int)" in rv + + if PY3: + assert "'bytes' must be a bytes (got str)" in rv + else: + assert "'bytes' must be a str (got unicode)" in rv + + +class TestGuessType(object): + @i_and_d_encoded + def test_success(self, type, hash): + """ + Returns Type.I on likely Argon2i hashes. + """ + assert type is guess_type(hash) + + @given(st.binary(max_size=128)) + def test_fail(self, wrong_hash): + """ + If the hash can't be neither i nor d, raise InvalidHash. + """ + with pytest.raises(InvalidHash): + guess_type(wrong_hash) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..ce6d056 --- /dev/null +++ b/tox.ini @@ -0,0 +1,41 @@ +[tox] +envlist = coverage-clean,py26,py27,py33,py34,py35,pypy,docs,flake8,manifest,coverage-report + + +[testenv] +passenv = TERM # ensure colors +deps = + coverage + hypothesis + hypothesis-pytest + pytest +commands = + coverage run --parallel -m pytest {posargs} + coverage run --parallel -m argon2 -n 1 + +[testenv:docs] +basepython = python3.5 +deps = +commands = python -m doctest README.rst + + +[testenv:flake8] +basepython = python3.5 +deps = flake8 +commands = flake8 src tests setup.py + +[testenv:manifest] +deps = check-manifest +commands = check-manifest + +[testenv:coverage-clean] +deps = coverage +skip_install = true +commands = coverage erase + +[testenv:coverage-report] +deps = coverage +skip_install = true +commands = + coverage combine + coverage report