From 7871027c9daa534517821509dfa0501b6df4dbc2 Mon Sep 17 00:00:00 2001 From: Matthias Fuchs Date: Mon, 27 Aug 2018 17:40:41 +0200 Subject: [PATCH] Share the implementation of hashing for both vars_prompt and password_hash (#21215) * Share the implementation of hashing for both vars_prompt and password_hash. * vars_prompt with encrypt does not require passlib for the algorithms supported by crypt. * Additional checks ensure that there is always a result. This works around issues in the crypt.crypt python function that returns None for algorithms it does not know. Some modules (like user module) interprets None as no password at all, which is misleading. * The password_hash filter supports all parameters of passlib. This allows users to provide a rounds parameter, fixing #15326. * password_hash is not restricted to the subset provided by crypt.crypt, fixing one half of #17266. * Updated documentation fixes other half of #17266. * password_hash does not hard-code the salt-length, which fixes bcrypt in connection with passlib. bcrypt requires a salt with length 22, which fixes #25347 * Salts are only generated by ansible when using crypt.crypt. Otherwise passlib generates them. * Avoids deprecated functionality of passlib with newer library versions. * When no rounds are specified for sha256/sha256_crypt and sha512/sha512_crypt always uses the default values used by crypt, i.e. 5000 rounds. Before when installed passlibs' defaults were used. passlib changes its defaults with newer library versions, leading to non idempotent behavior. NOTE: This will lead to the recalculation of existing hashes generated with passlib and without a rounds parameter. Yet henceforth the hashes will remain the same. No matter the installed passlib version. Making these hashes idempotent. Fixes #15326 Fixes #17266 Fixes #25347 except bcrypt still uses 2a, instead of the suggested 2b. * random_salt is solely handled by encrypt.py. There is no _random_salt function there anymore. Also the test moved to test_encrypt.py. * Uses pytest.skip when passlib is not available, instead of a silent return. * More checks are executed when passlib is not available. * Moves tests that require passlib into their own test-function. * Uses the six library to reraise the exception. * Fixes integration test. When no rounds are provided the defaults of crypt are used. In that case the rounds are not part of the resulting MCF output. --- changelogs/fragments/hashing-changes.yaml | 18 ++ .../rst/porting_guides/porting_guide_2.7.rst | 19 +- .../rst/user_guide/playbooks_filters.rst | 12 ++ .../rst/user_guide/playbooks_prompts.rst | 10 + lib/ansible/plugins/filter/core.py | 56 ++---- lib/ansible/plugins/lookup/password.py | 13 +- lib/ansible/utils/encrypt.py | 182 +++++++++++++++--- .../targets/vars_prompt/test-vars_prompt.py | 2 +- test/units/plugins/lookup/test_password.py | 9 - test/units/utils/test_encrypt.py | 133 +++++++++++++ 10 files changed, 361 insertions(+), 93 deletions(-) create mode 100644 changelogs/fragments/hashing-changes.yaml create mode 100644 test/units/utils/test_encrypt.py diff --git a/changelogs/fragments/hashing-changes.yaml b/changelogs/fragments/hashing-changes.yaml new file mode 100644 index 00000000000..87ebd41cc66 --- /dev/null +++ b/changelogs/fragments/hashing-changes.yaml @@ -0,0 +1,18 @@ +--- +bugfixes: +- vars_prompt with encrypt does not require passlib for the algorithms + supported by crypt. +- Additional checks ensure that there is always a result of hashing passwords + in the password_hash filter and vars_prompt, otherwise an error is returned. + Some modules (like user module) interprets None as no password at all, + which can be dangerous if the password given above is passed directly into + those modules. +- Avoids deprecated functionality of passlib with newer library versions. +- password_hash does not hard-code the salt-length, which fixes bcrypt + in connection with passlib as bcrypt requires a salt with length 22. +minor_changes: +- The password_hash filter supports all parameters of passlib. + This allows users to provide a rounds parameter. + (https://github.com/ansible/ansible/issues/15326) +- password_hash is not restricted to the subset provided by crypt.crypt + (https://github.com/ansible/ansible/issues/17266) diff --git a/docs/docsite/rst/porting_guides/porting_guide_2.7.rst b/docs/docsite/rst/porting_guides/porting_guide_2.7.rst index 21a78dab9c4..a6001f322c5 100644 --- a/docs/docsite/rst/porting_guides/porting_guide_2.7.rst +++ b/docs/docsite/rst/porting_guides/porting_guide_2.7.rst @@ -71,6 +71,16 @@ In Ansible 2.7 a new module argument named ``public`` was added to the ``include There is an important difference in the way that ``include_role`` (dynamic) will expose the role's variables, as opposed to ``import_role`` (static). ``import_role`` is a pre-processor, and the ``defaults`` and ``vars`` are evaluated at playbook parsing, making the variables available to tasks and roles listed at any point in the play. ``include_role`` is a conditional task, and the ``defaults`` and ``vars`` are evaluated at execution time, making the variables available to tasks and roles listed *after* the ``include_role`` task. +vars_prompt with unknown algorithms +----------------------------------- + +vars_prompt now throws an error if the hash algorithm specified in encrypt is not supported by +the controller. This increases the safety of vars_prompt as it previously returned None if the +algorithm was unknown. Some modules, notably the user module, treated a password of None as +a request not to set a password. If your playbook starts erroring because of this, change the +hashing algorithm being used with this filter. + + Deprecated ========== @@ -81,7 +91,7 @@ Expedited Deprecation: Use of ``__file__`` in ``AnsibleModule`` We are deprecating the use of the ``__file__`` variable to refer to the file containing the currently-running code. This common Python technique for finding a filesystem path does not always work (even in vanilla Python). Sometimes a Python module can be imported from a virtual location (like inside of a zip file). When this happens, the ``__file__`` variable will reference a virtual location pointing to inside of the zip file. This can cause problems if, for instance, the code was trying to use ``__file__`` to find the directory containing the python module to write some temporary information. -Before the introduction of AnsiBallZ in Ansible 2.1, using ``__file__`` worked in ``AnsibleModule`` sometimes, but any module that used it would fail when pipelining was turned on (because the module would be piped into the python interpreter's standard input, so ``__file__`` wouldn't contain a file path). AnsiBallZ unintentionally made using ``__file__`` always work, by always creating a temporary file for ``AnsibleModule`` to reside in. +Before the introduction of AnsiBallZ in Ansible 2.1, using ``__file__`` worked in ``AnsibleModule`` sometimes, but any module that used it would fail when pipelining was turned on (because the module would be piped into the python interpreter's standard input, so ``__file__`` wouldn't contain a file path). AnsiBallZ unintentionally made using ``__file__`` work, by always creating a temporary file for ``AnsibleModule`` to reside in. Ansible 2.8 will no longer create a temporary file for ``AnsibleModule``; instead it will read the file out of a zip file. This change should speed up module execution, but it does mean that starting with Ansible 2.8, referencing ``__file__`` will always fail in ``AnsibleModule``. @@ -190,7 +200,12 @@ Noteworthy module changes Plugins ======= -No notable changes. +* The hash_password filter now throws an error if the hash algorithm specified is not supported by + the controller. This increases the safety of the filter as it previously returned None if the + algorithm was unknown. Some modules, notably the user module, treated a password of None as + a request not to set a password. If your playbook starts erroring because of this, change the + hashing algorithm being used with this filter. + Porting custom scripts ====================== diff --git a/docs/docsite/rst/user_guide/playbooks_filters.rst b/docs/docsite/rst/user_guide/playbooks_filters.rst index 3916cf82b9a..d8cbebcc422 100644 --- a/docs/docsite/rst/user_guide/playbooks_filters.rst +++ b/docs/docsite/rst/user_guide/playbooks_filters.rst @@ -783,6 +783,18 @@ An idempotent method to generate unique hashes per system is to use a salt that Hash types available depend on the master system running ansible, 'hash' depends on hashlib password_hash depends on passlib (https://passlib.readthedocs.io/en/stable/lib/passlib.hash.html). +.. versionadded:: 2.7 + +Some hash types allow providing a rounds parameter:: + + {{ 'secretpassword'|password_hash('sha256', 'mysecretsalt', rounds=10000) }} + +When`Passlib `_ is installed +`password_hash` supports any crypt scheme and parameter supported by 'Passlib':: + + {{ 'secretpassword'|password_hash('sha256_crypt', 'mysecretsalt', rounds=5000) }} + {{ 'secretpassword'|password_hash('bcrypt', ident='2b', rounds=14) }} + .. _combine_filter: Combining hashes/dictionaries diff --git a/docs/docsite/rst/user_guide/playbooks_prompts.rst b/docs/docsite/rst/user_guide/playbooks_prompts.rst index 34300591593..6ae1655fe7b 100644 --- a/docs/docsite/rst/user_guide/playbooks_prompts.rst +++ b/docs/docsite/rst/user_guide/playbooks_prompts.rst @@ -90,6 +90,16 @@ However, the only parameters accepted are 'salt' or 'salt_size'. You can use you 'salt', or have one generated automatically using 'salt_size'. If nothing is specified, a salt of size 8 will be generated. +.. versionadded:: 2.7 + +When Passlib is not installed the `crypt `_ library is used as fallback. +Depending on your platform at most the following crypt schemes are supported: + +- *bcrypt* - BCrypt +- *md5_crypt* - MD5 Crypt +- *sha256_crypt* - SHA-256 Crypt +- *sha512_crypt* - SHA-512 Crypt + .. seealso:: :doc:`playbooks` diff --git a/lib/ansible/plugins/filter/core.py b/lib/ansible/plugins/filter/core.py index b56c5d527b9..17309dd8aa5 100644 --- a/lib/ansible/plugins/filter/core.py +++ b/lib/ansible/plugins/filter/core.py @@ -41,19 +41,14 @@ from random import Random, SystemRandom, shuffle, random from jinja2.filters import environmentfilter, do_groupby as _do_groupby -try: - import passlib.hash - HAS_PASSLIB = True -except ImportError: - HAS_PASSLIB = False - -from ansible.errors import AnsibleFilterError -from ansible.module_utils.six import iteritems, string_types, integer_types +from ansible.errors import AnsibleError, AnsibleFilterError +from ansible.module_utils.six import iteritems, string_types, integer_types, reraise from ansible.module_utils.six.moves import reduce, shlex_quote from ansible.module_utils._text import to_bytes, to_text from ansible.module_utils.common.collections import is_sequence from ansible.parsing.ajson import AnsibleJSONEncoder from ansible.parsing.yaml.dumper import AnsibleDumper +from ansible.utils.encrypt import passlib_or_crypt from ansible.utils.hashing import md5s, checksum_s from ansible.utils.unicode import unicode_wrap from ansible.utils.vars import merge_hash @@ -252,42 +247,19 @@ def get_hash(data, hashtype='sha1'): return h.hexdigest() -def get_encrypted_password(password, hashtype='sha512', salt=None): - - # TODO: find a way to construct dynamically from system - cryptmethod = { - 'md5': '1', - 'blowfish': '2a', - 'sha256': '5', - 'sha512': '6', +def get_encrypted_password(password, hashtype='sha512', salt=None, salt_size=None, rounds=None): + passlib_mapping = { + 'md5': 'md5_crypt', + 'blowfish': 'bcrypt', + 'sha256': 'sha256_crypt', + 'sha512': 'sha512_crypt', } - if hashtype in cryptmethod: - if salt is None: - r = SystemRandom() - if hashtype in ['md5']: - saltsize = 8 - else: - saltsize = 16 - saltcharset = string.ascii_letters + string.digits + '/.' - salt = ''.join([r.choice(saltcharset) for _ in range(saltsize)]) - - if not HAS_PASSLIB: - if sys.platform.startswith('darwin'): - raise AnsibleFilterError('|password_hash requires the passlib python module to generate password hashes on macOS/Darwin') - saltstring = "$%s$%s" % (cryptmethod[hashtype], salt) - encrypted = crypt.crypt(password, saltstring) - else: - if hashtype == 'blowfish': - cls = passlib.hash.bcrypt - else: - cls = getattr(passlib.hash, '%s_crypt' % hashtype) - - encrypted = cls.encrypt(password, salt=salt) - - return encrypted - - return None + hashtype = passlib_mapping.get(hashtype, hashtype) + try: + return passlib_or_crypt(password, hashtype, salt=salt, salt_size=salt_size, rounds=rounds) + except AnsibleError as e: + reraise(AnsibleFilterError, AnsibleFilterError(str(e), orig_exc=e), sys.exc_info()[2]) def to_uuid(string): diff --git a/lib/ansible/plugins/lookup/password.py b/lib/ansible/plugins/lookup/password.py index 2d41ba92629..3895e2ebd0c 100644 --- a/lib/ansible/plugins/lookup/password.py +++ b/lib/ansible/plugins/lookup/password.py @@ -100,7 +100,7 @@ from ansible.errors import AnsibleError, AnsibleAssertionError from ansible.module_utils._text import to_bytes, to_native, to_text from ansible.parsing.splitter import parse_kv from ansible.plugins.lookup import LookupBase -from ansible.utils.encrypt import do_encrypt, random_password +from ansible.utils.encrypt import do_encrypt, random_password, random_salt from ansible.utils.path import makedirs_safe @@ -207,15 +207,6 @@ def _gen_candidate_chars(characters): return chars -def _random_salt(): - """Return a text string suitable for use as a salt for the hash functions we use to encrypt passwords. - """ - # Note passlib salt values must be pure ascii so we can't let the user - # configure this - salt_chars = _gen_candidate_chars(['ascii_letters', 'digits', './']) - return random_password(length=8, chars=salt_chars) - - def _parse_content(content): '''parse our password data format into password and salt @@ -329,7 +320,7 @@ class LookupModule(LookupBase): if params['encrypt'] and not salt: changed = True - salt = _random_salt() + salt = random_salt() if changed and b_path != to_bytes('/dev/null'): content = _format_content(plaintext_password, salt, encrypt=params['encrypt']) diff --git a/lib/ansible/utils/encrypt.py b/lib/ansible/utils/encrypt.py index 5e34beddbda..4445fb87c81 100644 --- a/lib/ansible/utils/encrypt.py +++ b/lib/ansible/utils/encrypt.py @@ -4,18 +4,25 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type +import crypt import multiprocessing import random +import string +import sys + +from collections import namedtuple from ansible import constants as C from ansible.errors import AnsibleError, AnsibleAssertionError from ansible.module_utils.six import text_type from ansible.module_utils._text import to_text, to_bytes - PASSLIB_AVAILABLE = False try: + import passlib import passlib.hash + from passlib.utils.handlers import HasRawSalt + PASSLIB_AVAILABLE = True except: pass @@ -33,33 +40,6 @@ _LOCK = multiprocessing.Lock() DEFAULT_PASSWORD_LENGTH = 20 -def do_encrypt(result, encrypt, salt_size=None, salt=None): - if PASSLIB_AVAILABLE: - try: - crypt = getattr(passlib.hash, encrypt) - except: - raise AnsibleError("passlib does not support '%s' algorithm" % encrypt) - - if salt_size: - result = crypt.encrypt(result, salt_size=salt_size) - elif salt: - if crypt._salt_is_bytes: - salt = to_bytes(salt, encoding='ascii', errors='strict') - else: - salt = to_text(salt, encoding='ascii', errors='strict') - result = crypt.encrypt(result, salt=salt) - else: - result = crypt.encrypt(result) - else: - raise AnsibleError("passlib must be installed to encrypt vars_prompt values") - - # Hashes from passlib.hash should be represented as ascii strings of hex - # digits so this should not traceback. If it's not representable as such - # we need to traceback and then blacklist such algorithms because it may - # impact calling code. - return to_text(result, errors='strict') - - def random_password(length=DEFAULT_PASSWORD_LENGTH, chars=C.DEFAULT_PASSWORD_CHARS): '''Return a random password string of length containing only chars @@ -72,3 +52,149 @@ def random_password(length=DEFAULT_PASSWORD_LENGTH, chars=C.DEFAULT_PASSWORD_CHA random_generator = random.SystemRandom() return u''.join(random_generator.choice(chars) for dummy in range(length)) + + +def random_salt(length=8): + """Return a text string suitable for use as a salt for the hash functions we use to encrypt passwords. + """ + # Note passlib salt values must be pure ascii so we can't let the user + # configure this + salt_chars = string.ascii_letters + string.digits + u'./' + return random_password(length=length, chars=salt_chars) + + +class BaseHash(object): + algo = namedtuple('algo', ['crypt_id', 'salt_size', 'implicit_rounds']) + algorithms = { + 'md5_crypt': algo(crypt_id='1', salt_size=8, implicit_rounds=None), + 'bcrypt': algo(crypt_id='2a', salt_size=22, implicit_rounds=None), + 'sha256_crypt': algo(crypt_id='5', salt_size=16, implicit_rounds=5000), + 'sha512_crypt': algo(crypt_id='6', salt_size=16, implicit_rounds=5000), + } + + def __init__(self, algorithm): + self.algorithm = algorithm + + +class CryptHash(BaseHash): + def __init__(self, algorithm): + super(CryptHash, self).__init__(algorithm) + + if sys.platform.startswith('darwin'): + raise AnsibleError("crypt.crypt not supported on Mac OS X/Darwin, install passlib python module") + + if algorithm not in self.algorithms: + raise AnsibleError("crypt.crypt does not support '%s' algorithm" % self.algorithm) + self.algo_data = self.algorithms[algorithm] + + def hash(self, secret, salt=None, salt_size=None, rounds=None): + salt = self._salt(salt, salt_size) + rounds = self._rounds(rounds) + return self._hash(secret, salt, rounds) + + def _salt(self, salt, salt_size): + salt_size = salt_size or self.algo_data.salt_size + return salt or random_salt(salt_size) + + def _rounds(self, rounds): + if rounds == self.algo_data.implicit_rounds: + # Passlib does not include the rounds if it is the same as implict_rounds. + # Make crypt lib behave the same, by not explicitly specifying the rounds in that case. + return None + else: + return rounds + + def _hash(self, secret, salt, rounds): + if rounds is None: + saltstring = "$%s$%s" % (self.algo_data.crypt_id, salt) + else: + saltstring = "$%s$rounds=%d$%s" % (self.algo_data.crypt_id, rounds, salt) + result = crypt.crypt(secret, saltstring) + + # crypt.crypt returns None if it cannot parse saltstring + # None as result would be interpreted by the some modules (user module) + # as no password at all. + if not result: + raise AnsibleError("crypt.crypt does not support '%s' algorithm" % self.algorithm) + + return result + + +class PasslibHash(BaseHash): + def __init__(self, algorithm): + super(PasslibHash, self).__init__(algorithm) + + if not PASSLIB_AVAILABLE: + raise AnsibleError("passlib must be installed to hash with '%s'" % algorithm) + + try: + self.crypt_algo = getattr(passlib.hash, algorithm) + except: + raise AnsibleError("passlib does not support '%s' algorithm" % algorithm) + + def hash(self, secret, salt=None, salt_size=None, rounds=None): + salt = self._clean_salt(salt) + rounds = self._clean_rounds(rounds) + return self._hash(secret, salt=salt, salt_size=salt_size, rounds=rounds) + + def _clean_salt(self, salt): + if not salt: + return None + elif issubclass(self.crypt_algo, HasRawSalt): + return to_bytes(salt, encoding='ascii', errors='strict') + else: + return to_text(salt, encoding='ascii', errors='strict') + + def _clean_rounds(self, rounds): + algo_data = self.algorithms.get(self.algorithm) + if rounds: + return rounds + elif algo_data and algo_data.implicit_rounds: + # The default rounds used by passlib depend on the passlib version. + # For consistency ensure that passlib behaves the same as crypt in case no rounds were specified. + # Thus use the crypt defaults. + return algo_data.implicit_rounds + else: + return None + + def _hash(self, secret, salt, salt_size, rounds): + # Not every hash algorithm supports every paramter. + # Thus create the settings dict only with set parameters. + settings = {} + if salt: + settings['salt'] = salt + if salt_size: + settings['salt_size'] = salt_size + if rounds: + settings['rounds'] = rounds + + # starting with passlib 1.7 'using' and 'hash' should be used instead of 'encrypt' + if hasattr(self.crypt_algo, 'hash'): + result = self.crypt_algo.using(**settings).hash(secret) + elif hasattr(self.crypt_algo, 'encrypt'): + result = self.crypt_algo.encrypt(secret, **settings) + else: + raise AnsibleError("installed passlib version %s not supported" % passlib.__version__) + + # passlib.hash should always return something or raise an exception. + # Still ensure that there is always a result. + # Otherwise an empty password might be assumed by some modules, like the user module. + if not result: + raise AnsibleError("failed to hash with algorithm '%s'" % self.algorithm) + + # Hashes from passlib.hash should be represented as ascii strings of hex + # digits so this should not traceback. If it's not representable as such + # we need to traceback and then blacklist such algorithms because it may + # impact calling code. + return to_text(result, errors='strict') + + +def passlib_or_crypt(secret, algorithm, salt=None, salt_size=None, rounds=None): + if PASSLIB_AVAILABLE: + return PasslibHash(algorithm).hash(secret, salt=salt, salt_size=salt_size, rounds=rounds) + else: + return CryptHash(algorithm).hash(secret, salt=salt, salt_size=salt_size, rounds=rounds) + + +def do_encrypt(result, encrypt, salt_size=None, salt=None): + return passlib_or_crypt(result, encrypt, salt_size=salt_size, salt=salt) diff --git a/test/integration/targets/vars_prompt/test-vars_prompt.py b/test/integration/targets/vars_prompt/test-vars_prompt.py index 39f834af4c7..45b9f017ff4 100644 --- a/test/integration/targets/vars_prompt/test-vars_prompt.py +++ b/test/integration/targets/vars_prompt/test-vars_prompt.py @@ -94,7 +94,7 @@ tests = [ 'test_spec': [ [('password', 'Scenic-Improving-Payphone\r'), ('confirm password', 'Scenic-Improving-Payphone\r')], - r'"password": "\$6\$rounds=']}, + r'"password": "\$6\$']}, # Test variables in prompt field # https://github.com/ansible/ansible/issues/32723 diff --git a/test/units/plugins/lookup/test_password.py b/test/units/plugins/lookup/test_password.py index 9dcd783ee2e..8f3e31750da 100644 --- a/test/units/plugins/lookup/test_password.py +++ b/test/units/plugins/lookup/test_password.py @@ -292,15 +292,6 @@ class TestRandomPassword(unittest.TestCase): (char, candidate_chars, params['chars'])) -class TestRandomSalt(unittest.TestCase): - def test(self): - res = password._random_salt() - expected_salt_candidate_chars = u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789./' - self.assertEquals(len(res), 8) - for res_char in res: - self.assertIn(res_char, expected_salt_candidate_chars) - - class TestParseContent(unittest.TestCase): def test_empty_password_file(self): plaintext_password, salt = password._parse_content(u'') diff --git a/test/units/utils/test_encrypt.py b/test/units/utils/test_encrypt.py new file mode 100644 index 00000000000..22dd76287f0 --- /dev/null +++ b/test/units/utils/test_encrypt.py @@ -0,0 +1,133 @@ +# (c) 2018, Matthias Fuchs +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +import pytest +import sys + +from ansible.errors import AnsibleError, AnsibleFilterError +from ansible.plugins.filter.core import get_encrypted_password +from ansible.utils import encrypt + + +class passlib_off(object): + def __init__(self): + self.orig = encrypt.PASSLIB_AVAILABLE + + def __enter__(self): + encrypt.PASSLIB_AVAILABLE = False + return self + + def __exit__(self, exception_type, exception_value, traceback): + encrypt.PASSLIB_AVAILABLE = self.orig + + +def assert_hash(expected, secret, algorithm, **settings): + assert encrypt.CryptHash(algorithm).hash(secret, **settings) == expected + + if encrypt.PASSLIB_AVAILABLE: + assert encrypt.passlib_or_crypt(secret, algorithm, **settings) == expected + assert encrypt.PasslibHash(algorithm).hash(secret, **settings) == expected + else: + with pytest.raises(AnsibleFilterError): + encrypt.PasslibHash(algorithm).hash(secret, **settings) + + +def test_encrypt_with_rounds(): + assert_hash("$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7", + secret="123", algorithm="sha256_crypt", salt="12345678", rounds=5000) + assert_hash("$5$rounds=10000$12345678$JBinliYMFEcBeAXKZnLjenhgEhTmJBvZn3aR8l70Oy/", + secret="123", algorithm="sha256_crypt", salt="12345678", rounds=10000) + assert_hash("$6$12345678$LcV9LQiaPekQxZ.OfkMADjFdSO2k9zfbDQrHPVcYjSLqSdjLYpsgqviYvTEP/R41yPmhH3CCeEDqVhW1VHr3L.", + secret="123", algorithm="sha512_crypt", salt="12345678", rounds=5000) + + +def test_encrypt_default_rounds(): + assert_hash("$1$12345678$tRy4cXc3kmcfRZVj4iFXr/", + secret="123", algorithm="md5_crypt", salt="12345678") + assert_hash("$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7", + secret="123", algorithm="sha256_crypt", salt="12345678") + assert_hash("$6$12345678$LcV9LQiaPekQxZ.OfkMADjFdSO2k9zfbDQrHPVcYjSLqSdjLYpsgqviYvTEP/R41yPmhH3CCeEDqVhW1VHr3L.", + secret="123", algorithm="sha512_crypt", salt="12345678") + + assert encrypt.CryptHash("md5_crypt").hash("123") + + +def test_password_hash_filter_no_passlib(): + with passlib_off(): + assert not encrypt.PASSLIB_AVAILABLE + assert get_encrypted_password("123", "md5", salt="12345678") == "$1$12345678$tRy4cXc3kmcfRZVj4iFXr/" + + with pytest.raises(AnsibleFilterError): + get_encrypted_password("123", "crypt16", salt="12") + + +def test_password_hash_filter_passlib(): + if not encrypt.PASSLIB_AVAILABLE: + pytest.skip("passlib not available") + + with pytest.raises(AnsibleFilterError): + get_encrypted_password("123", "sha257", salt="12345678") + + # Uses 5000 rounds by default for sha256 matching crypt behaviour + assert get_encrypted_password("123", "sha256", salt="12345678") == "$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7" + assert get_encrypted_password("123", "sha256", salt="12345678", rounds=5000) == "$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7" + + assert (get_encrypted_password("123", "sha256", salt="12345678", rounds=10000) == + "$5$rounds=10000$12345678$JBinliYMFEcBeAXKZnLjenhgEhTmJBvZn3aR8l70Oy/") + + assert (get_encrypted_password("123", "sha512", salt="12345678", rounds=6000) == + "$6$rounds=6000$12345678$l/fC67BdJwZrJ7qneKGP1b6PcatfBr0dI7W6JLBrsv8P1wnv/0pu4WJsWq5p6WiXgZ2gt9Aoir3MeORJxg4.Z/") + + assert (get_encrypted_password("123", "sha512", salt="12345678", rounds=5000) == + "$6$12345678$LcV9LQiaPekQxZ.OfkMADjFdSO2k9zfbDQrHPVcYjSLqSdjLYpsgqviYvTEP/R41yPmhH3CCeEDqVhW1VHr3L.") + + assert get_encrypted_password("123", "crypt16", salt="12") == "12pELHK2ME3McUFlHxel6uMM" + + # Try algorithm that uses a raw salt + assert get_encrypted_password("123", "pbkdf2_sha256") + + +def test_do_encrypt_no_passlib(): + with passlib_off(): + assert not encrypt.PASSLIB_AVAILABLE + assert encrypt.do_encrypt("123", "md5_crypt", salt="12345678") == "$1$12345678$tRy4cXc3kmcfRZVj4iFXr/" + + with pytest.raises(AnsibleError): + encrypt.do_encrypt("123", "crypt16", salt="12") + + +def test_do_encrypt_passlib(): + if not encrypt.PASSLIB_AVAILABLE: + pytest.skip("passlib not available") + + with pytest.raises(AnsibleError): + encrypt.do_encrypt("123", "sha257_crypt", salt="12345678") + + # Uses 5000 rounds by default for sha256 matching crypt behaviour. + assert encrypt.do_encrypt("123", "sha256_crypt", salt="12345678") == "$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7" + + assert encrypt.do_encrypt("123", "md5_crypt", salt="12345678") == "$1$12345678$tRy4cXc3kmcfRZVj4iFXr/" + + assert encrypt.do_encrypt("123", "crypt16", salt="12") == "12pELHK2ME3McUFlHxel6uMM" + + +def test_random_salt(): + res = encrypt.random_salt() + expected_salt_candidate_chars = u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789./' + assert len(res) == 8 + for res_char in res: + assert res_char in expected_salt_candidate_chars