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